├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── cfg.png ├── cfg_loaded.png ├── dependabot.yml ├── run.png └── workflows │ ├── black.yml_disabled │ ├── codeql-analysis.yml │ ├── docker.yml │ └── format.yml ├── LICENSE ├── README.md ├── apps └── automoli │ └── automoli.py ├── demo ├── Dockerfile ├── automoli.appdaemon.yaml └── automoli.apps.yaml ├── hacs.json └── pyproject.toml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: benleb 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 14 | 15 | 😩 **Describe the issue/bug** 16 | A clear and concise description of what the bug is. 17 | 18 | 😒 **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | 🎛️ **Configuration** 22 | ```yaml 23 | [relevant configuration from your apps.yml file] 24 | ``` 25 | 26 | 🧠 **System** 27 | - AppDaemon: **[e.g. 4.0.5]** 28 | - running mode: **[e.g. virtualenv, hassio-plugin, docker]** 29 | - Home-Assistant: **[e.g. 0.118.3, 0.119.0b0]** 30 | - running mode: **[e.g. virtualenv, hassio, docker]** 31 | - Python: **[e.g. 3.8.6, 3.9]** 32 | 33 | 🗒️ **Logs** 34 | 35 | ```log 36 | log log log... 37 | ``` 38 | 39 | 🧁 **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 🙄 **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | 😍 **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | 18 | 19 | 🖼️ **Example/See here!** 20 | List other projects implementing a (similar) feature or other information and links that help to describe your requested feature. 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/cfg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benleb/ad-automoli/14920bb2ea8d2e31e0320b825f5eb7dd042bc1c9/.github/cfg.png -------------------------------------------------------------------------------- /.github/cfg_loaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benleb/ad-automoli/14920bb2ea8d2e31e0320b825f5eb7dd042bc1c9/.github/cfg_loaded.png -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" -------------------------------------------------------------------------------- /.github/run.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benleb/ad-automoli/14920bb2ea8d2e31e0320b825f5eb7dd042bc1c9/.github/run.png -------------------------------------------------------------------------------- /.github/workflows/black.yml_disabled: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-python@v2 11 | - uses: psf/black@stable 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [master] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [master] 14 | schedule: 15 | - cron: '0 13 * * 0' 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: build multi-arch images 2 | 3 | on: { push: { branches: ["master"] }, workflow_dispatch: {} } 4 | 5 | jobs: 6 | build: 7 | runs-on: "ubuntu-latest" 8 | continue-on-error: ${{ matrix.fail-ok }} 9 | strategy: 10 | max-parallel: 5 11 | fail-fast: false 12 | matrix: 13 | platform: ["linux/amd64", "linux/arm64"] 14 | fail-ok: [false] 15 | include: [{ platform: "linux/arm/v7", fail-ok: true }] 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Set up QEMU 22 | if: ${{ runner.os == 'Linux' }} 23 | uses: docker/setup-qemu-action@v1 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v1 27 | 28 | - name: Login to DockerHub 29 | uses: docker/login-action@v1 30 | with: 31 | username: ${{ secrets.DOCKER_USERNAME }} 32 | password: ${{ secrets.DOCKER_PASSWORD }} 33 | 34 | - name: Login to GitHub Container Registry 35 | uses: docker/login-action@v1 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.repository_owner }} 39 | password: ${{ secrets.CR_PAT }} 40 | 41 | - name: "${{ matrix.platform }} build and push" 42 | continue-on-error: ${{ matrix.fail-ok }} 43 | id: docker_build 44 | uses: docker/build-push-action@v2 45 | with: 46 | push: true 47 | context: . 48 | file: ./demo/Dockerfile 49 | tags: | 50 | benleb/automoli:latest 51 | benleb/automoli:0.11.4 52 | ghcr.io/benleb/ad-automoli:latest 53 | ghcr.io/benleb/ad-automoli:0.11.4 54 | platforms: ${{ matrix.platform }} 55 | build-args: | 56 | APPDAEMON_VERSION=4.0.6 57 | 58 | - name: Image digest 59 | run: echo ${{ steps.docker_build.outputs.digest }} 60 | continue-on-error: ${{ matrix.fail-ok }} 61 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-python@v2 11 | - name: black 12 | uses: lgeiger/black-action@master 13 | with: { args: . --check --diff } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) >=2019 Ben Lebherz 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [![automoli](https://socialify.git.ci/benleb/ad-automoli/image?description=1&font=KoHo&forks=1&language=1&logo=https%3A%2F%2Femojipedia-us.s3.dualstack.us-west-1.amazonaws.com%2Fthumbs%2F240%2Fapple%2F237%2Felectric-light-bulb_1f4a1.png&owner=1&pulls=1&stargazers=1&theme=Light)](https://github.com/benleb/ad-automoli) 2 | 3 | 4 | 5 | 6 | 7 | Fully *automatic light management* based on motion as [AppDaemon](https://github.com/home-assistant/appdaemon) app. 8 | 9 | 🕓 multiple **daytimes** to define different scenes for morning, noon, ... 10 | 💡 supports **Hue** (for Hue Rooms/Groups) & **Home Assistant** scenes 11 | 🔌 switches **lights** and **plugs** (with lights) 12 | ☀️ supports **illumination sensors** to switch the light just if needed 13 | 💦 supports **humidity sensors** as blocker (the "*shower case*") 14 | 🔍 **automatic** discovery of **lights** and **sensors** 15 | ⛰️ **stable** and **tested** by many people with different homes 16 | 17 | ## Getting Started 18 | 19 | ### Docker Image (`amd64`, `arm` and `arm64`) 20 | 21 | You can try [**AutoMoLi**](https://github.com/benleb/ad-automoli) via [Docker](https://hub.docker.com/r/benleb/automoli) without installing anything! The Image is the default [AppDaemon](https://github.com/AppDaemon/appdaemon) one with AutoMoLi and a simple default configuration added. See the [AppDaemon Docker Tutorial](https://appdaemon.readthedocs.io/en/latest/DOCKER_TUTORIAL.html) on how to use it in general. 22 | 23 | [**AutoMoLi**](https://github.com/benleb/ad-automoli) expects motion sensors and lights including a `room` name. The exact patterns are listed in [Auto-Discovery of Lights and Sensors](https://github.com/benleb/ad-automoli#auto-discovery-of-lights-and-sensors) You can set a `room` with the **AUTOMOLI_ROOM** variable in the Docker *run* command. 24 | 25 | ```bash 26 | docker run --rm --interactive --tty --name AutoMoLi \ 27 | --env HA_URL="" \ 28 | --env TOKEN="" \ 29 | --env AUTOMOLI_ROOM="bathroom" \ 30 | --ports 5050:5050 \ 31 | benleb/automoli:latest 32 | ``` 33 | 34 | Port 5050 is opened to give access to the AppDaemon Admin-UI at 35 | 36 | #### Example 37 | 38 | To test AutoMoLi in your **Esszimmer** (german for dining room), use `... --env AUTOMOLI_ROOM="esszimmer" ...` in the Docker *run* command. 39 | 40 | * AppDaemon will show you its config file on startup: ![cfg](https://raw.githubusercontent.com/benleb/ad-automoli/master/.github/cfg.png) 41 | 42 | * If everything works, AutoMoLi will show you the configuration it has parsed, including the discovered sensors: ![cfg-loaded](https://raw.githubusercontent.com/benleb/ad-automoli/master/.github/cfg_loaded.png) 43 | 44 | * This is how it looks when AutoMoLi manages your lights: ![running](https://raw.githubusercontent.com/benleb/ad-automoli/master/.github/run.png) 45 | 46 | ## Installation 47 | 48 | Use [HACS](https://github.com/hacs/integration) or [download](https://github.com/benleb/ad-automoli/releases) the `automoli` directory from inside the `apps` directory here to your local `apps` directory, then add the configuration to enable the `automoli` module. 49 | 50 | ### Example App Configuration 51 | 52 | Add your configuration to appdaemon/apps/apps.yaml, an example with two rooms is below. 53 | 54 | ```yaml 55 | livingroom: 56 | module: automoli 57 | class: AutoMoLi 58 | room: livingroom 59 | disable_switch_entities: 60 | - input_boolean.automoli 61 | - input_boolean.disable_my_house 62 | delay: 600 63 | daytimes: 64 | # This rule "morning" uses a scene, the scene.livingroom_morning Home Assistant scene will be used 65 | - { starttime: "sunrise", name: morning, light: "scene.livingroom_morning" } 66 | 67 | - { starttime: "07:30", name: day, light: "scene.livingroom_working" } 68 | 69 | # This rule"evening" uses a percentage brightness value, and the lights specified in lights: below will be set to 90% 70 | - { starttime: "sunset-01:00", name: evening, light: 90 } 71 | 72 | - { starttime: "22:30", name: night, light: 20 } 73 | 74 | # This rule has the lights set to 0, so they will no turn on during this time period 75 | - { starttime: "23:30", name: more_night, light: 0 } 76 | 77 | # If you are using an illuminance sensor you can set the lowest value here that blocks the lights turning on if its already light enough 78 | illuminance: sensor.illuminance_livingroom 79 | illuminance_threshold: 100 80 | 81 | # You can specify a light group or list of lights here 82 | lights: 83 | - light.livingroom 84 | 85 | # You can specify a list of motion sensors here 86 | motion: 87 | - binary_sensor.motion_sensor_153d000224f421 88 | - binary_sensor.motion_sensor_128d4101b95fb7 89 | 90 | # See below for info on humidity 91 | humidity: 92 | - sensor.humidity_128d4101b95fb7 93 | 94 | 95 | bathroom: 96 | module: automoli 97 | class: AutoMoLi 98 | room: bathroom 99 | delay: 180 100 | motion_state_on: "on" 101 | motion_state_off: "off" 102 | daytimes: 103 | - { starttime: "05:30", name: morning, light: 45 } 104 | - { starttime: "07:30", name: day, light: "scene.bathroom_day" } 105 | - { starttime: "20:30", name: evening, light: 100 } 106 | - { starttime: "sunset+01:00", name: night, light: 0 } 107 | 108 | # As this is a bathroom there could be the case that when taking a bath or shower, motion is not detected and the lights turn off, which isnt helpful, so the following settings allow you to use a humidity sensor and humidity threshold to prevent this by detecting the humidity from the shower and blocking the lights turning off. 109 | humidity: 110 | - sensor.humidity_128d4101b95fb7 111 | humidity_threshold: 75 112 | 113 | lights: 114 | - light.bathroom 115 | - switch.plug_68fe8b4c9fa1 116 | motion: 117 | - binary_sensor.motion_sensor_158d033224e141 118 | ``` 119 | 120 | ## Auto-Discovery of Lights and Sensors 121 | 122 | [**AutoMoLi**](https://github.com/benleb/ad-automoli) is built around **rooms**. Every room or area in your home is represented as a seperate app in [AppDaemon](https://github.com/AppDaemon/appdaemon) with separat light setting. In your configuration you will have **one config block** for every **room**, see example configuration. 123 | For the auto-discovery of your lights and sensors to work, AutoMoLi expects motion sensors and lights including a **room** name (can also be something else than a real room) like below: 124 | 125 | * *sensor.illumination_`room`* 126 | * *binary_sensor.motion_sensor_`room`* 127 | * *binary_sensor.motion_sensor_`room`_something* 128 | * *light.`room`* 129 | 130 | AutoMoLi will detect them automatically. Manually configured entities will take precedence, but **need** to follow the naming scheme above. 131 | 132 | ## Configuration Options 133 | 134 | key | optional | type | default | description 135 | -- | -- | -- | -- | -- 136 | `module` | False | string | automoli | The module name of the app. 137 | `class` | False | string | AutoMoLi | The name of the Class. 138 | `room` | False | string | | The "room" used to find matching sensors/light 139 | `disable_switch_entities` | True | list/string | | One or more Home Assistant Entities as switch for AutoMoLi. If the state of **any** entity is *off*, AutoMoLi is *deactivated*. (Use an *input_boolean* for example) 140 | `only_own_events` | True | bool | | Track if automoli switched this light on. If not, an existing timer will be deleted and the state will not change 141 | `disable_switch_states` | True | list/string | ["off"] | Custom states for `disable_switch_entities`. If the state of **any** entity is *in this list*, AutoMoLi is *deactivated*. Can be used to disable with `media_players` in `playing` state for example. 142 | `disable_hue_groups` | False | boolean | | Disable the use of Hue Groups/Scenes 143 | `delay` | True | integer | 150 | Seconds without motion until lights will switched off. Can be disabled (lights stay always on) with `0` 144 | ~~`motion_event`~~ | ~~True~~ | ~~string~~ | | **replaced by `motion_state_on/off`** 145 | `daytimes` | True | list | *see code* | Different daytimes with light settings (see below) 146 | `transition_on_daytime_switch` | True | bool | False | directly activate a daytime on its start time (instead to just set it as active daytime used if lights are switched from off to on) 147 | `lights` | True | list/string | *auto detect* | Light entities 148 | `motion` | True | list/string | *auto detect* | Motion sensor entities 149 | `illuminance` | True | list/string | | Illuminance sensor entities 150 | `illuminance_threshold` | True | integer | | If illuminance is *above* this value, lights will *not switched on* 151 | `humidity` | True | list/string | | Humidity sensor entities 152 | `humidity_threshold` | True | integer | | If humidity is *above* this value, lights will *not switched off* 153 | `motion_state_on` | True | integer | | If using motion sensors which don't send events if already activated, like Xiaomi do, add this to your config with "on". This will listen to state changes instead 154 | `motion_state_off` | True | integer | | If using motion sensors which don't send events if already activated, like Xiaomi do, add this to your config with "off". This will listen to the state changes instead. 155 | `debug_log` | True | bool | false | Activate debug logging (for this room) 156 | 157 | ### daytimes 158 | 159 | key | optional | type | default | description 160 | -- | -- | -- | -- | -- 161 | `starttime` | False | string | | Time this daytime starts or sunrise|sunset [+|- HH:MM] 162 | `name` | False | string | | A name for this daytime 163 | `delay` | True | integer | 150 | Seconds without motion until lights will switched off. Can be disabled (lights stay always on) with `0`. Setting this will overwrite the global `delay` setting for this daytime. 164 | `light` | False | integer/string | | Light setting (percent integer value (0-100) in or scene entity 165 | 166 | --- 167 | 168 | 171 | 172 | 173 | 174 | ## Meta 175 | 176 | **Ben Lebherz**: *automation lover ⚙️ developer & maintainer* - [@benleb](https://github.com/benleb) | [@ben_leb](https://twitter.com/ben_leb) 177 | 178 | 179 | 180 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 181 | -------------------------------------------------------------------------------- /apps/automoli/automoli.py: -------------------------------------------------------------------------------- 1 | """AutoMoLi. 2 | Automatic Motion Lights 3 | @benleb / https://github.com/benleb/ad-automoli 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import asyncio 9 | from collections.abc import Coroutine, Iterable 10 | from copy import deepcopy 11 | from datetime import time 12 | from distutils.version import StrictVersion 13 | from enum import Enum, IntEnum 14 | from inspect import stack 15 | import logging 16 | from pprint import pformat 17 | import random 18 | from typing import Any 19 | 20 | # pylint: disable=import-error 21 | import hassapi as hass 22 | 23 | __version__ = "0.11.4" 24 | 25 | APP_NAME = "AutoMoLi" 26 | APP_ICON = "💡" 27 | 28 | ON_ICON = APP_ICON 29 | OFF_ICON = "🌑" 30 | DIM_ICON = "🔜" 31 | DAYTIME_SWITCH_ICON = "⏰" 32 | 33 | # default values 34 | DEFAULT_NAME = "daytime" 35 | DEFAULT_LIGHT_SETTING = 100 36 | DEFAULT_DELAY = 150 37 | DEFAULT_DIM_METHOD = "step" 38 | DEFAULT_DAYTIMES: list[dict[str, str | int]] = [ 39 | dict(starttime="05:30", name="morning", light=25), 40 | dict(starttime="07:30", name="day", light=100), 41 | dict(starttime="20:30", name="evening", light=90), 42 | dict(starttime="22:30", name="night", light=0), 43 | ] 44 | DEFAULT_LOGLEVEL = "INFO" 45 | 46 | EVENT_MOTION_XIAOMI = "xiaomi_aqara.motion" 47 | 48 | RANDOMIZE_SEC = 5 49 | SECONDS_PER_MIN: int = 60 50 | 51 | 52 | class EntityType(Enum): 53 | LIGHT = "light." 54 | MOTION = "binary_sensor.motion_sensor_" 55 | HUMIDITY = "sensor.humidity_" 56 | ILLUMINANCE = "sensor.illumination_" 57 | DOOR_WINDOW = "binary_sensor.door_window_sensor_" 58 | 59 | @property 60 | def idx(self) -> str: 61 | return self.name.casefold() 62 | 63 | @property 64 | def prefix(self) -> str: 65 | return str(self.value).casefold() 66 | 67 | 68 | SENSORS_REQUIRED = [EntityType.MOTION.idx] 69 | SENSORS_OPTIONAL = [EntityType.HUMIDITY.idx, EntityType.ILLUMINANCE.idx] 70 | 71 | KEYWORDS = { 72 | EntityType.LIGHT.idx: "light.", 73 | EntityType.MOTION.idx: "binary_sensor.motion_sensor_", 74 | EntityType.HUMIDITY.idx: "sensor.humidity_", 75 | EntityType.ILLUMINANCE.idx: "sensor.illumination_", 76 | EntityType.DOOR_WINDOW.idx: "binary_sensor.door_window_sensor_", 77 | } 78 | 79 | 80 | def install_pip_package( 81 | pkg: str, 82 | version: str = "", 83 | install_name: str | None = None, 84 | pre_release: bool = False, 85 | ) -> None: 86 | import importlib 87 | import site 88 | from subprocess import check_call # nosec 89 | import sys 90 | 91 | try: 92 | importlib.import_module(pkg) 93 | except ImportError: 94 | install_name = install_name if install_name else pkg 95 | if pre_release: 96 | check_call( 97 | [ 98 | sys.executable, 99 | "-m", 100 | "pip", 101 | "install", 102 | "--upgrade", 103 | "--pre", 104 | f"{install_name}{version}", 105 | ] 106 | ) 107 | else: 108 | check_call( 109 | [ 110 | sys.executable, 111 | "-m", 112 | "pip", 113 | "install", 114 | "--upgrade", 115 | f"{install_name}{version}", 116 | ] 117 | ) 118 | importlib.reload(site) 119 | finally: 120 | importlib.import_module(pkg) 121 | 122 | 123 | # install adutils library 124 | install_pip_package("adutils", version=">=0.6.2") 125 | from adutils import Room, hl, natural_time, py38_or_higher, py39_or_higher # noqa 126 | from adutils import py37_or_higher # noqa 127 | 128 | 129 | class DimMethod(IntEnum): 130 | """IntEnum representing the transition-to-off method used.""" 131 | 132 | NONE = 0 133 | TRANSITION = 1 134 | STEP = 2 135 | 136 | 137 | class AutoMoLi(hass.Hass): # type: ignore 138 | """Automatic Motion Lights.""" 139 | 140 | def lg( 141 | self, 142 | msg: str, 143 | *args: Any, 144 | level: int | None = None, 145 | icon: str | None = None, 146 | repeat: int = 1, 147 | log_to_ha: bool = False, 148 | **kwargs: Any, 149 | ) -> None: 150 | kwargs.setdefault("ascii_encode", False) 151 | 152 | level = level if level else self.loglevel 153 | 154 | if level >= self.loglevel: 155 | message = f"{f'{icon} ' if icon else ' '}{msg}" 156 | _ = [self.log(message, *args, **kwargs) for _ in range(repeat)] 157 | 158 | if log_to_ha or self.log_to_ha: 159 | message = message.replace("\033[1m", "").replace("\033[0m", "") 160 | 161 | # Python community recommend a strategy of 162 | # "easier to ask for forgiveness than permission" 163 | # https://stackoverflow.com/a/610923/13180763 164 | try: 165 | ha_name = self.room.name.capitalize() 166 | except AttributeError: 167 | ha_name = APP_NAME 168 | self.lg( 169 | "No room set yet, using 'AutoMoLi' forlogging to HA", 170 | level=logging.DEBUG, 171 | ) 172 | 173 | self.call_service( 174 | "logbook/log", 175 | name=ha_name, # type:ignore 176 | message=message, # type:ignore 177 | entity_id="light.esszimmer_decke", # type:ignore 178 | ) 179 | 180 | def listr( 181 | self, 182 | list_or_string: list[str] | set[str] | str | Any, 183 | entities_exist: bool = True, 184 | ) -> set[str]: 185 | entity_list: list[str] = [] 186 | 187 | if isinstance(list_or_string, str): 188 | entity_list.append(list_or_string) 189 | elif isinstance(list_or_string, (list, set)): 190 | entity_list += list_or_string 191 | elif list_or_string: 192 | self.lg( 193 | f"{list_or_string} is of type {type(list_or_string)} and " 194 | f"not 'Union[List[str], Set[str], str]'" 195 | ) 196 | 197 | return set( 198 | filter(self.entity_exists, entity_list) if entities_exist else entity_list 199 | ) 200 | 201 | async def initialize(self) -> None: 202 | """Initialize a room with AutoMoLi.""" 203 | 204 | # pylint: disable=attribute-defined-outside-init 205 | 206 | self.icon = APP_ICON 207 | 208 | # get a real dict for the configuration 209 | self.args: dict[str, Any] = dict(self.args) 210 | 211 | self.loglevel = ( 212 | logging.DEBUG if self.args.get("debug_log", False) else logging.INFO 213 | ) 214 | 215 | self.log_to_ha = self.args.get("log_to_ha", False) 216 | 217 | # notification thread (prevents doubled messages) 218 | self.notify_thread = random.randint(0, 9) # nosec 219 | 220 | self.lg( 221 | f"setting log level to {logging.getLevelName(self.loglevel)}", 222 | level=logging.DEBUG, 223 | ) 224 | 225 | # python version check 226 | if not py39_or_higher: 227 | self.lg("") 228 | self.lg(f" hey, what about trying {hl('Python >= 3.9')}‽ 🤪") 229 | self.lg("") 230 | if not py38_or_higher: 231 | icon_alert = "⚠️" 232 | self.lg("", icon=icon_alert) 233 | self.lg("") 234 | self.lg( 235 | f" please update to {hl('Python >= 3.8')} at least! 🤪", icon=icon_alert 236 | ) 237 | self.lg("") 238 | self.lg("", icon=icon_alert) 239 | if not py37_or_higher: 240 | raise ValueError 241 | 242 | # set room 243 | self.room_name = str(self.args.pop("room")) 244 | 245 | # general delay 246 | self.delay = int(self.args.pop("delay", DEFAULT_DELAY)) 247 | 248 | # directly switch to new daytime light settings 249 | self.transition_on_daytime_switch: bool = bool( 250 | self.args.pop("transition_on_daytime_switch", False) 251 | ) 252 | 253 | # state values 254 | self.states = { 255 | "motion_on": self.args.pop("motion_state_on", None), 256 | "motion_off": self.args.pop("motion_state_off", None), 257 | } 258 | 259 | # threshold values 260 | self.thresholds = { 261 | "humidity": self.args.pop("humidity_threshold", None), 262 | EntityType.ILLUMINANCE.idx: self.args.pop("illuminance_threshold", None), 263 | } 264 | 265 | # experimental dimming features 266 | self.dimming: bool = False 267 | self.dim: dict[str, int | DimMethod] = {} 268 | if (dim := self.args.pop("dim", {})) and ( 269 | seconds_before := dim.pop("seconds_before", None) 270 | ): 271 | 272 | brightness_step_pct = dim.pop("brightness_step_pct", None) 273 | 274 | dim_method: DimMethod | None = None 275 | if method := dim.pop("method", None): 276 | dim_method = ( 277 | DimMethod.TRANSITION 278 | if method.lower() == "transition" 279 | else DimMethod.STEP 280 | ) 281 | elif brightness_step_pct: 282 | dim_method = DimMethod.TRANSITION 283 | else: 284 | dim_method = DimMethod.NONE 285 | 286 | self.dim = { # type: ignore 287 | "brightness_step_pct": brightness_step_pct, 288 | "seconds_before": int(seconds_before), 289 | "method": dim_method.value, 290 | } 291 | 292 | # night mode settings 293 | self.night_mode: dict[str, int | str] = {} 294 | if night_mode := self.args.pop("night_mode", {}): 295 | self.night_mode = await self.configure_night_mode(night_mode) 296 | 297 | # on/off switch via input.boolean 298 | self.disable_switch_entities: set[str] = self.listr( 299 | self.args.pop("disable_switch_entities", set()) 300 | ) 301 | self.disable_switch_states: set[str] = self.listr( 302 | self.args.pop("disable_switch_states", set(["off"])) 303 | ) 304 | 305 | # store if an entity has been switched on by automoli 306 | self.only_own_events: bool = bool(self.args.pop("only_own_events", False)) 307 | self._switched_on_by_automoli: set[str] = set() 308 | 309 | self.disable_hue_groups: bool = self.args.pop("disable_hue_groups", False) 310 | 311 | # eol of the old option name 312 | if "disable_switch_entity" in self.args: 313 | icon_alert = "⚠️" 314 | self.lg("", icon=icon_alert) 315 | self.lg( 316 | f" please migrate {hl('disable_switch_entity')} to {hl('disable_switch_entities')}", 317 | icon=icon_alert, 318 | ) 319 | self.lg("", icon=icon_alert) 320 | self.args.pop("disable_switch_entity") 321 | return 322 | 323 | # currently active daytime settings 324 | self.active: dict[str, int | str] = {} 325 | 326 | # entity lists for initial discovery 327 | states = await self.get_state() 328 | 329 | self.handle_turned_off: str | None = None 330 | 331 | # define light entities switched by automoli 332 | self.lights: set[str] = self.args.pop("lights", set()) 333 | if not self.lights: 334 | room_light_group = f"light.{self.room_name}" 335 | if await self.entity_exists(room_light_group): 336 | self.lights.add(room_light_group) 337 | else: 338 | self.lights.update( 339 | await self.find_sensors( 340 | EntityType.LIGHT.prefix, self.room_name, states 341 | ) 342 | ) 343 | 344 | # sensors 345 | self.sensors: dict[str, Any] = {} 346 | 347 | # enumerate sensors for motion detection 348 | self.sensors[EntityType.MOTION.idx] = self.listr( 349 | self.args.pop( 350 | "motion", 351 | await self.find_sensors( 352 | EntityType.MOTION.prefix, self.room_name, states 353 | ), 354 | ) 355 | ) 356 | 357 | self.room = Room( 358 | name=self.room_name, 359 | room_lights=self.lights, 360 | motion=self.sensors[EntityType.MOTION.idx], 361 | door_window=set(), 362 | temperature=set(), 363 | push_data=dict(), 364 | appdaemon=self.get_ad_api(), 365 | ) 366 | 367 | # requirements check 368 | if not self.lights or not self.sensors[EntityType.MOTION.idx]: 369 | self.lg("") 370 | self.lg( 371 | f"{hl('No lights/sensors')} given and none found with name: " 372 | f"'{hl(EntityType.LIGHT.prefix)}*{hl(self.room.name)}*' or " 373 | f"'{hl(EntityType.MOTION.prefix)}*{hl(self.room.name)}*'", 374 | icon="⚠️ ", 375 | ) 376 | self.lg("") 377 | self.lg(" docs: https://github.com/benleb/ad-automoli") 378 | self.lg("") 379 | return 380 | 381 | # enumerate optional sensors & disable optional features if sensors are not available 382 | for sensor_type in SENSORS_OPTIONAL: 383 | 384 | if sensor_type in self.thresholds and self.thresholds[sensor_type]: 385 | self.sensors[sensor_type] = self.listr( 386 | self.args.pop(sensor_type, None) 387 | ) or await self.find_sensors( 388 | KEYWORDS[sensor_type], self.room_name, states 389 | ) 390 | 391 | self.lg(f"{self.sensors[sensor_type] = }", level=logging.DEBUG) 392 | 393 | else: 394 | self.lg( 395 | f"No {sensor_type} sensors → disabling features based on {sensor_type}" 396 | f" - {self.thresholds[sensor_type]}.", 397 | level=logging.DEBUG, 398 | ) 399 | del self.thresholds[sensor_type] 400 | 401 | # use user-defined daytimes if available 402 | daytimes = await self.build_daytimes( 403 | self.args.pop("daytimes", DEFAULT_DAYTIMES) 404 | ) 405 | 406 | # set up event listener for each sensor 407 | listener: set[Coroutine[Any, Any, Any]] = set() 408 | for sensor in self.sensors[EntityType.MOTION.idx]: 409 | 410 | # listen to xiaomi sensors by default 411 | if not any([self.states["motion_on"], self.states["motion_off"]]): 412 | self.lg( 413 | "no motion states configured - using event listener", 414 | level=logging.DEBUG, 415 | ) 416 | listener.add( 417 | self.listen_event( 418 | self.motion_event, event=EVENT_MOTION_XIAOMI, entity_id=sensor 419 | ) 420 | ) 421 | 422 | # on/off-only sensors without events on every motion 423 | elif all([self.states["motion_on"], self.states["motion_off"]]): 424 | self.lg( 425 | "both motion states configured - using state listener", 426 | level=logging.DEBUG, 427 | ) 428 | listener.add( 429 | self.listen_state( 430 | self.motion_detected, 431 | entity_id=sensor, 432 | new=self.states["motion_on"], 433 | ) 434 | ) 435 | listener.add( 436 | self.listen_state( 437 | self.motion_cleared, 438 | entity_id=sensor, 439 | new=self.states["motion_off"], 440 | ) 441 | ) 442 | 443 | self.args.update( 444 | { 445 | "room": self.room_name.capitalize(), 446 | "delay": self.delay, 447 | "active_daytime": self.active_daytime, 448 | "daytimes": daytimes, 449 | "lights": self.lights, 450 | "dim": self.dim, 451 | "sensors": self.sensors, 452 | "disable_hue_groups": self.disable_hue_groups, 453 | "only_own_events": self.only_own_events, 454 | "loglevel": self.loglevel, 455 | } 456 | ) 457 | 458 | if self.thresholds: 459 | self.args.update({"thresholds": self.thresholds}) 460 | 461 | # add night mode to config if enabled 462 | if self.night_mode: 463 | self.args.update({"night_mode": self.night_mode}) 464 | 465 | # add disable entity to config if given 466 | if self.disable_switch_entities: 467 | self.args.update({"disable_switch_entities": self.disable_switch_entities}) 468 | self.args.update({"disable_switch_states": self.disable_switch_states}) 469 | 470 | # show parsed config 471 | self.show_info(self.args) 472 | 473 | await asyncio.gather(*listener) 474 | await self.refresh_timer() 475 | 476 | async def switch_daytime(self, kwargs: dict[str, Any]) -> None: 477 | """Set new light settings according to daytime.""" 478 | 479 | daytime = kwargs.get("daytime") 480 | 481 | if daytime is not None: 482 | self.active = daytime 483 | if not kwargs.get("initial"): 484 | 485 | delay = daytime["delay"] 486 | light_setting = daytime["light_setting"] 487 | if isinstance(light_setting, str): 488 | is_scene = True 489 | # if its a ha scene, remove the "scene." part 490 | if "." in light_setting: 491 | light_setting = (light_setting.split("."))[1] 492 | else: 493 | is_scene = False 494 | 495 | self.lg( 496 | f"{stack()[0][3]}: {self.transition_on_daytime_switch = }", 497 | level=logging.DEBUG, 498 | ) 499 | 500 | action_done = "set" 501 | 502 | if self.transition_on_daytime_switch and any( 503 | [await self.get_state(light) == "on" for light in self.lights] 504 | ): 505 | await self.lights_on(force=True) 506 | action_done = "activated" 507 | 508 | self.lg( 509 | f"{action_done} daytime {hl(daytime['daytime'])} → " 510 | f"{'scene' if is_scene else 'brightness'}: {hl(light_setting)}" 511 | f"{'' if is_scene else '%'}, delay: {hl(natural_time(delay))}", 512 | icon=DAYTIME_SWITCH_ICON, 513 | ) 514 | 515 | async def motion_cleared( 516 | self, entity: str, attribute: str, old: str, new: str, _: dict[str, Any] 517 | ) -> None: 518 | """wrapper for motion sensors that do not push a certain event but. 519 | instead the default HA `state_changed` event is used for presence detection 520 | schedules the callback to switch the lights off after a `state_changed` callback 521 | of a motion sensors changing to "cleared" is received 522 | """ 523 | 524 | # starte the timer if motion is cleared 525 | self.lg( 526 | f"{stack()[0][3]}: {entity} changed {attribute} from {old} to {new}", 527 | level=logging.DEBUG, 528 | ) 529 | 530 | if all( 531 | [ 532 | await self.get_state(sensor) == self.states["motion_off"] 533 | for sensor in self.sensors[EntityType.MOTION.idx] 534 | ] 535 | ): 536 | # all motion sensors off, starting timer 537 | await self.refresh_timer() 538 | else: 539 | # cancel scheduled callbacks 540 | await self.clear_handles() 541 | 542 | async def motion_detected( 543 | self, entity: str, attribute: str, old: str, new: str, kwargs: dict[str, Any] 544 | ) -> None: 545 | """wrapper for motion sensors that do not push a certain event but. 546 | instead the default HA `state_changed` event is used for presence detection 547 | maps the `state_changed` callback of a motion sensors changing to "detected" 548 | to the `event` callback` 549 | """ 550 | 551 | self.lg( 552 | f"{stack()[0][3]}: {entity} changed {attribute} from {old} to {new}", 553 | level=logging.DEBUG, 554 | ) 555 | 556 | # cancel scheduled callbacks 557 | await self.clear_handles() 558 | 559 | self.lg( 560 | f"{stack()[0][3]}: handles cleared and cancelled all scheduled timers" 561 | f" | {self.dimming = }", 562 | level=logging.DEBUG, 563 | ) 564 | 565 | # calling motion event handler 566 | data: dict[str, Any] = {"entity_id": entity, "new": new, "old": old} 567 | await self.motion_event("state_changed_detection", data, kwargs) 568 | 569 | async def motion_event( 570 | self, event: str, data: dict[str, str], _: dict[str, Any] 571 | ) -> None: 572 | """Main handler for motion events.""" 573 | 574 | self.lg( 575 | f"{stack()[0][3]}: received '{hl(event)}' event from " 576 | f"'{data['entity_id'].replace(EntityType.MOTION.prefix, '')}' | {self.dimming = }", 577 | level=logging.DEBUG, 578 | ) 579 | 580 | # check if automoli is disabled via home assistant entity 581 | self.lg( 582 | f"{stack()[0][3]}: {await self.is_disabled() = } | {self.dimming = }", 583 | level=logging.DEBUG, 584 | ) 585 | if await self.is_disabled(): 586 | return 587 | 588 | # turn on the lights if not already 589 | if self.dimming or not any( 590 | [await self.get_state(light) == "on" for light in self.lights] 591 | ): 592 | self.lg( 593 | f"{stack()[0][3]}: switching on | {self.dimming = }", 594 | level=logging.DEBUG, 595 | ) 596 | await self.lights_on() 597 | else: 598 | self.lg( 599 | f"{stack()[0][3]}: light in {self.room.name.capitalize()} already on → refreshing " 600 | f"timer | {self.dimming = }", 601 | level=logging.DEBUG, 602 | ) 603 | 604 | if event != "state_changed_detection": 605 | await self.refresh_timer() 606 | 607 | def has_min_ad_version(self, required_version: str) -> bool: 608 | required_version = required_version if required_version else "4.0.7" 609 | return bool( 610 | StrictVersion(self.get_ad_version()) >= StrictVersion(required_version) 611 | ) 612 | 613 | async def clear_handles(self, handles: set[str] = None) -> None: 614 | """clear scheduled timers/callbacks.""" 615 | 616 | if not handles: 617 | handles = deepcopy(self.room.handles_automoli) 618 | self.room.handles_automoli.clear() 619 | 620 | if self.has_min_ad_version("4.0.7"): 621 | await asyncio.gather( 622 | *[ 623 | self.cancel_timer(handle) 624 | for handle in handles 625 | if await self.timer_running(handle) 626 | ] 627 | ) 628 | else: 629 | await asyncio.gather(*[self.cancel_timer(handle) for handle in handles]) 630 | 631 | self.lg(f"{stack()[0][3]}: cancelled scheduled callbacks", level=logging.DEBUG) 632 | 633 | async def refresh_timer(self) -> None: 634 | """refresh delay timer.""" 635 | 636 | fnn = f"{stack()[0][3]}:" 637 | 638 | # leave dimming state 639 | self.dimming = False 640 | 641 | dim_in_sec = 0 642 | 643 | # cancel scheduled callbacks 644 | await self.clear_handles() 645 | 646 | # if no delay is set or delay = 0, lights will not switched off by AutoMoLi 647 | if delay := self.active.get("delay"): 648 | 649 | self.lg( 650 | f"{fnn} {self.active = } | {delay = } | {self.dim = }", 651 | level=logging.DEBUG, 652 | ) 653 | 654 | if self.dim: 655 | dim_in_sec = int(delay) - self.dim["seconds_before"] 656 | self.lg(f"{fnn} {dim_in_sec = }", level=logging.DEBUG) 657 | 658 | handle = await self.run_in(self.dim_lights, (dim_in_sec)) 659 | 660 | else: 661 | handle = await self.run_in(self.lights_off, delay) 662 | 663 | self.room.handles_automoli.add(handle) 664 | 665 | if timer_info := await self.info_timer(handle): 666 | self.lg( 667 | f"{fnn} scheduled callback to switch off the lights in {dim_in_sec}s " 668 | f"({timer_info[0].isoformat()}) | " 669 | f"handles: {self.room.handles_automoli = }", 670 | level=logging.DEBUG, 671 | ) 672 | 673 | async def night_mode_active(self) -> bool: 674 | return bool( 675 | self.night_mode and await self.get_state(self.night_mode["entity"]) == "on" 676 | ) 677 | 678 | async def is_disabled(self) -> bool: 679 | """check if automoli is disabled via home assistant entity""" 680 | for entity in self.disable_switch_entities: 681 | if ( 682 | state := await self.get_state(entity, copy=False) 683 | ) and state in self.disable_switch_states: 684 | self.lg(f"{APP_NAME} is disabled by {entity} with {state = }") 685 | return True 686 | 687 | return False 688 | 689 | async def is_blocked(self) -> bool: 690 | 691 | # the "shower case" 692 | if humidity_threshold := self.thresholds.get("humidity"): 693 | 694 | for sensor in self.sensors[EntityType.HUMIDITY.idx]: 695 | try: 696 | current_humidity = float( 697 | await self.get_state(sensor) # type:ignore 698 | ) 699 | except ValueError as error: 700 | self.lg( 701 | f"self.get_state(sensor) raised a ValueError for {sensor}: {error}", 702 | level=logging.ERROR, 703 | ) 704 | continue 705 | 706 | self.lg( 707 | f"{stack()[0][3]}: {current_humidity = } >= {humidity_threshold = } " 708 | f"= {current_humidity >= humidity_threshold}", 709 | level=logging.DEBUG, 710 | ) 711 | 712 | if current_humidity >= humidity_threshold: 713 | 714 | await self.refresh_timer() 715 | self.lg( 716 | f"🛁 no motion in {hl(self.room.name.capitalize())} since " 717 | f"{hl(natural_time(int(self.active['delay'])))} → " 718 | f"but {hl(current_humidity)}%RH > " 719 | f"{hl(humidity_threshold)}%RH" 720 | ) 721 | return True 722 | 723 | return False 724 | 725 | async def dim_lights(self, _: Any) -> None: 726 | 727 | message: str = "" 728 | 729 | self.lg( 730 | f"{stack()[0][3]}: {await self.is_disabled() = } | {await self.is_blocked() = }", 731 | level=logging.DEBUG, 732 | ) 733 | 734 | # check if automoli is disabled via home assistant entity or blockers like the "shower case" 735 | if (await self.is_disabled()) or (await self.is_blocked()): 736 | return 737 | 738 | if not any([await self.get_state(light) == "on" for light in self.lights]): 739 | return 740 | 741 | dim_method: DimMethod 742 | seconds_before: int = 10 743 | 744 | if ( 745 | self.dim 746 | and (dim_method := DimMethod(self.dim["method"])) 747 | and dim_method != DimMethod.NONE 748 | ): 749 | 750 | seconds_before = int(self.dim["seconds_before"]) 751 | dim_attributes: dict[str, int] = {} 752 | 753 | self.lg( 754 | f"{stack()[0][3]}: {dim_method = } | {seconds_before = }", 755 | level=logging.DEBUG, 756 | ) 757 | 758 | if dim_method == DimMethod.STEP: 759 | dim_attributes = { 760 | "brightness_step_pct": int(self.dim["brightness_step_pct"]) 761 | } 762 | message = ( 763 | f"{hl(self.room.name.capitalize())} → " 764 | f"dim to {hl(self.dim['brightness_step_pct'])} | " 765 | f"{hl('off')} in {natural_time(seconds_before)}" 766 | ) 767 | 768 | elif dim_method == DimMethod.TRANSITION: 769 | dim_attributes = {"transition": int(seconds_before)} 770 | message = ( 771 | f"{hl(self.room.name.capitalize())} → transition to " 772 | f"{hl('off')} ({natural_time(seconds_before)})" 773 | ) 774 | 775 | self.dimming = True 776 | 777 | self.lg( 778 | f"{stack()[0][3]}: {dim_attributes = } | {self.dimming = }", 779 | level=logging.DEBUG, 780 | ) 781 | 782 | self.lg(f"{stack()[0][3]}: {self.room.room_lights = }", level=logging.DEBUG) 783 | self.lg( 784 | f"{stack()[0][3]}: {self.room.lights_dimmable = }", level=logging.DEBUG 785 | ) 786 | self.lg( 787 | f"{stack()[0][3]}: {self.room.lights_undimmable = }", 788 | level=logging.DEBUG, 789 | ) 790 | 791 | if self.room.lights_undimmable: 792 | for light in self.room.lights_dimmable: 793 | 794 | await self.call_service( 795 | "light/turn_off", 796 | entity_id=light, # type:ignore 797 | **dim_attributes, # type:ignore 798 | ) 799 | await self.set_state(entity_id=light, state="off") 800 | 801 | # workaround to switch off lights that do not support dimming 802 | if self.room.room_lights: 803 | self.room.handles_automoli.add( 804 | await self.run_in( 805 | self.turn_off_lights, 806 | seconds_before, 807 | lights=self.room.room_lights, 808 | ) 809 | ) 810 | 811 | self.lg(message, icon=OFF_ICON, level=logging.DEBUG) 812 | 813 | async def turn_off_lights(self, kwargs: dict[str, Any]) -> None: 814 | if lights := kwargs.get("lights"): 815 | self.lg(f"{stack()[0][3]}: {lights = }", level=logging.DEBUG) 816 | for light in lights: 817 | await self.call_service("homeassistant/turn_off", entity_id=light) 818 | self.run_in_thread(self.turned_off, thread=self.notify_thread) 819 | 820 | async def lights_on(self, force: bool = False) -> None: 821 | """Turn on the lights.""" 822 | 823 | self.lg( 824 | f"{stack()[0][3]}: {self.thresholds.get(EntityType.ILLUMINANCE.idx) = }" 825 | f" | {self.dimming = } | {force = } | {bool(force or self.dimming) = }", 826 | level=logging.DEBUG, 827 | ) 828 | 829 | force = bool(force or self.dimming) 830 | 831 | if illuminance_threshold := self.thresholds.get(EntityType.ILLUMINANCE.idx): 832 | 833 | # the "eco mode" check 834 | for sensor in self.sensors[EntityType.ILLUMINANCE.idx]: 835 | self.lg( 836 | f"{stack()[0][3]}: {self.thresholds.get(EntityType.ILLUMINANCE.idx) = } | " 837 | f"{float(await self.get_state(sensor)) = }", # type:ignore 838 | level=logging.DEBUG, 839 | ) 840 | try: 841 | if ( 842 | illuminance := float( 843 | await self.get_state(sensor) # type:ignore 844 | ) # type:ignore 845 | ) >= illuminance_threshold: 846 | self.lg( 847 | f"According to {hl(sensor)} its already bright enough ¯\\_(ツ)_/¯" 848 | f" | {illuminance} >= {illuminance_threshold}" 849 | ) 850 | return 851 | 852 | except ValueError as error: 853 | self.lg( 854 | f"could not parse illuminance '{await self.get_state(sensor)}' " 855 | f"from '{sensor}': {error}" 856 | ) 857 | return 858 | 859 | light_setting = ( 860 | self.active.get("light_setting") 861 | if not await self.night_mode_active() 862 | else self.night_mode.get("light") 863 | ) 864 | 865 | if isinstance(light_setting, str): 866 | 867 | # last check until we switch the lights on... really! 868 | if not force and any( 869 | [await self.get_state(light) == "on" for light in self.lights] 870 | ): 871 | self.lg("¯\\_(ツ)_/¯") 872 | return 873 | 874 | for entity in self.lights: 875 | 876 | if self.active["is_hue_group"] and await self.get_state( 877 | entity_id=entity, attribute="is_hue_group" 878 | ): 879 | await self.call_service( 880 | "hue/hue_activate_scene", 881 | group_name=await self.friendly_name(entity), # type:ignore 882 | scene_name=light_setting, # type:ignore 883 | ) 884 | if self.only_own_events: 885 | self._switched_on_by_automoli.add(entity) 886 | continue 887 | 888 | item = light_setting if light_setting.startswith("scene.") else entity 889 | 890 | await self.call_service( 891 | "homeassistant/turn_on", entity_id=item # type:ignore 892 | ) # type:ignore 893 | if self.only_own_events: 894 | self._switched_on_by_automoli.add(item) 895 | 896 | self.lg( 897 | f"{hl(self.room.name.capitalize())} turned {hl('on')} → " 898 | f"{'hue' if self.active['is_hue_group'] else 'ha'} scene: " 899 | f"{hl(light_setting.replace('scene.', ''))}" 900 | f" | delay: {hl(natural_time(int(self.active['delay'])))}", 901 | icon=ON_ICON, 902 | ) 903 | 904 | elif isinstance(light_setting, int): 905 | 906 | if light_setting == 0: 907 | await self.lights_off({}) 908 | 909 | else: 910 | # last check until we switch the lights on... really! 911 | if not force and any( 912 | [await self.get_state(light) == "on" for light in self.lights] 913 | ): 914 | self.lg("¯\\_(ツ)_/¯") 915 | return 916 | 917 | for entity in self.lights: 918 | if entity.startswith("switch."): 919 | await self.call_service( 920 | "homeassistant/turn_on", entity_id=entity # type:ignore 921 | ) 922 | else: 923 | await self.call_service( 924 | "homeassistant/turn_on", 925 | entity_id=entity, # type:ignore 926 | brightness_pct=light_setting, # type:ignore 927 | ) 928 | 929 | self.lg( 930 | f"{hl(self.room.name.capitalize())} turned {hl('on')} → " 931 | f"brightness: {hl(light_setting)}%" 932 | f" | delay: {hl(natural_time(int(self.active['delay'])))}", 933 | icon=ON_ICON, 934 | ) 935 | if self.only_own_events: 936 | self._switched_on_by_automoli.add(entity) 937 | 938 | else: 939 | raise ValueError( 940 | f"invalid brightness/scene: {light_setting!s} " f"in {self.room}" 941 | ) 942 | 943 | async def lights_off(self, _: dict[str, Any]) -> None: 944 | """Turn off the lights.""" 945 | 946 | self.lg( 947 | f"{stack()[0][3]} {await self.is_disabled()} | {await self.is_blocked() = }", 948 | level=logging.DEBUG, 949 | ) 950 | 951 | # check if automoli is disabled via home assistant entity or blockers like the "shower case" 952 | if (await self.is_disabled()) or (await self.is_blocked()): 953 | return 954 | 955 | # cancel scheduled callbacks 956 | await self.clear_handles() 957 | 958 | self.lg( 959 | f"{stack()[0][3]}: " 960 | f"{any([await self.get_state(entity) == 'on' for entity in self.lights]) = }" 961 | f" | {self.lights = }", 962 | level=logging.DEBUG, 963 | ) 964 | 965 | # if any([await self.get_state(entity) == "on" for entity in self.lights]): 966 | if all([await self.get_state(entity) == "off" for entity in self.lights]): 967 | return 968 | 969 | at_least_one_turned_off = False 970 | for entity in self.lights: 971 | if self.only_own_events: 972 | if entity in self._switched_on_by_automoli: 973 | await self.call_service( 974 | "homeassistant/turn_off", entity_id=entity # type:ignore 975 | ) # type:ignore 976 | self._switched_on_by_automoli.remove(entity) 977 | at_least_one_turned_off = True 978 | else: 979 | await self.call_service( 980 | "homeassistant/turn_off", entity_id=entity # type:ignore 981 | ) # type:ignore 982 | at_least_one_turned_off = True 983 | if at_least_one_turned_off: 984 | self.run_in_thread(self.turned_off, thread=self.notify_thread) 985 | 986 | # experimental | reset for xiaomi "super motion" sensors | idea from @wernerhp 987 | # app: https://github.com/wernerhp/appdaemon_aqara_motion_sensors 988 | # mod: 989 | # https://community.smartthings.com/t/making-xiaomi-motion-sensor-a-super-motion-sensor/139806 990 | for sensor in self.sensors[EntityType.MOTION.idx]: 991 | await self.set_state( 992 | sensor, 993 | state="off", 994 | attributes=(await self.get_state(sensor, attribute="all")).get( 995 | "attributes", {} 996 | ), 997 | ) 998 | 999 | async def turned_off(self, _: dict[str, Any] | None = None) -> None: 1000 | # cancel scheduled callbacks 1001 | await self.clear_handles() 1002 | 1003 | self.lg( 1004 | f"no motion in {hl(self.room.name.capitalize())} since " 1005 | f"{hl(natural_time(int(self.active['delay'])))} → turned {hl('off')}", 1006 | icon=OFF_ICON, 1007 | ) 1008 | 1009 | async def find_sensors( 1010 | self, keyword: str, room_name: str, states: dict[str, dict[str, Any]] 1011 | ) -> list[str]: 1012 | """Find sensors by looking for a keyword in the friendly_name.""" 1013 | 1014 | def lower_umlauts(text: str, single: bool = True) -> str: 1015 | return ( 1016 | text.replace("ä", "a") 1017 | .replace("ö", "o") 1018 | .replace("ü", "u") 1019 | .replace("ß", "s") 1020 | if single 1021 | else text.replace("ä", "ae") 1022 | .replace("ö", "oe") 1023 | .replace("ü", "ue") 1024 | .replace("ß", "ss") 1025 | ).lower() 1026 | 1027 | matches: list[str] = [] 1028 | for state in states.values(): 1029 | if keyword in (entity_id := state.get("entity_id", "")) and lower_umlauts( 1030 | room_name 1031 | ) in "|".join( 1032 | [ 1033 | entity_id, 1034 | lower_umlauts(state.get("attributes", {}).get("friendly_name", "")), 1035 | ] 1036 | ): 1037 | matches.append(entity_id) 1038 | 1039 | return matches 1040 | 1041 | async def configure_night_mode( 1042 | self, night_mode: dict[str, int | str] 1043 | ) -> dict[str, int | str]: 1044 | 1045 | # check if a enable/disable entity is given and exists 1046 | if not ( 1047 | (nm_entity := night_mode.pop("entity")) 1048 | and await self.entity_exists(nm_entity) 1049 | ): 1050 | self.lg("no night_mode entity given", level=logging.DEBUG) 1051 | return {} 1052 | 1053 | if not (nm_light_setting := night_mode.pop("light")): 1054 | return {} 1055 | 1056 | return {"entity": nm_entity, "light": nm_light_setting} 1057 | 1058 | async def build_daytimes( 1059 | self, daytimes: list[Any] 1060 | ) -> list[dict[str, int | str]] | None: 1061 | starttimes: set[time] = set() 1062 | 1063 | for idx, daytime in enumerate(daytimes): 1064 | dt_name = daytime.get("name", f"{DEFAULT_NAME}_{idx}") 1065 | dt_delay = daytime.get("delay", self.delay) 1066 | dt_light_setting = daytime.get("light", DEFAULT_LIGHT_SETTING) 1067 | if self.disable_hue_groups: 1068 | dt_is_hue_group = False 1069 | else: 1070 | dt_is_hue_group = ( 1071 | isinstance(dt_light_setting, str) 1072 | and not dt_light_setting.startswith("scene.") 1073 | and any( 1074 | await asyncio.gather( 1075 | *[ 1076 | self.get_state( 1077 | entity_id=entity, attribute="is_hue_group" 1078 | ) 1079 | for entity in self.lights 1080 | ] 1081 | ) 1082 | ) 1083 | ) 1084 | 1085 | dt_start: time 1086 | try: 1087 | starttime = daytime.get("starttime") 1088 | if starttime.count(":") == 1: 1089 | starttime += ":00" 1090 | dt_start = (await self.parse_time(starttime)).replace( 1091 | microsecond=0 1092 | ) 1093 | daytime["starttime"] = dt_start 1094 | except ValueError as error: 1095 | raise ValueError( 1096 | f"missing start time in daytime '{dt_name}': {error}" 1097 | ) from error 1098 | 1099 | # configuration for this daytime 1100 | daytime = dict( 1101 | daytime=dt_name, 1102 | delay=dt_delay, 1103 | starttime=dt_start.isoformat(), # datetime is not serializable 1104 | light_setting=dt_light_setting, 1105 | is_hue_group=dt_is_hue_group, 1106 | ) 1107 | 1108 | # info about next daytime 1109 | next_dt_name = DEFAULT_NAME 1110 | try: 1111 | next_starttime = str( 1112 | daytimes[(idx + 1) % len(daytimes)].get("starttime") 1113 | ) 1114 | if next_starttime.count(":") == 1: 1115 | next_starttime += ":00" 1116 | next_dt_name = str(daytimes[(idx + 1) % len(daytimes)].get("name")) 1117 | next_dt_start = ( 1118 | await self.parse_time(next_starttime) 1119 | ).replace(microsecond=0) 1120 | except ValueError as error: 1121 | raise ValueError( 1122 | f"missing start time in daytime '{next_dt_name}': {error}" 1123 | ) from error 1124 | 1125 | # collect all start times for sanity check 1126 | if dt_start in starttimes: 1127 | raise ValueError( 1128 | f"Start times of all daytimes have to be unique! " 1129 | f"Duplicate found: {dt_start}" 1130 | ) 1131 | 1132 | starttimes.add(dt_start) 1133 | 1134 | # check if this daytime should ne active now 1135 | if await self.now_is_between(str(dt_start), str(next_dt_start)): 1136 | await self.switch_daytime(dict(daytime=daytime, initial=True)) 1137 | self.active_daytime = daytime.get("daytime") 1138 | 1139 | # schedule callbacks for daytime switching 1140 | await self.run_daily( 1141 | self.switch_daytime, 1142 | dt_start, 1143 | random_start=-RANDOMIZE_SEC, 1144 | random_end=RANDOMIZE_SEC, 1145 | **dict(daytime=daytime), 1146 | ) 1147 | 1148 | return daytimes 1149 | 1150 | def show_info(self, config: dict[str, Any] | None = None) -> None: 1151 | # check if a room is given 1152 | 1153 | if config: 1154 | self.config = config 1155 | 1156 | if not self.config: 1157 | self.lg("no configuration available", icon="‼️", level=logging.ERROR) 1158 | return 1159 | 1160 | room = "" 1161 | if "room" in self.config: 1162 | room = f" · {hl(self.config['room'].capitalize())}" 1163 | 1164 | self.lg("", log_to_ha=False) 1165 | self.lg( 1166 | f"{hl(APP_NAME)} v{hl(__version__)}{room}", icon=self.icon, log_to_ha=False 1167 | ) 1168 | self.lg("", log_to_ha=False) 1169 | 1170 | listeners = self.config.pop("listeners", None) 1171 | 1172 | for key, value in self.config.items(): 1173 | 1174 | # hide "internal keys" when displaying config 1175 | if key in ["module", "class"] or key.startswith("_"): 1176 | continue 1177 | 1178 | if isinstance(value, (list, set)): 1179 | self.print_collection(key, value, 2) 1180 | elif isinstance(value, dict): 1181 | self.print_collection(key, value, 2) 1182 | else: 1183 | self._print_cfg_setting(key, value, 2) 1184 | 1185 | if listeners: 1186 | self.lg(" event listeners:", log_to_ha=False) 1187 | for listener in sorted(listeners): 1188 | self.lg(f" · {hl(listener)}", log_to_ha=False) 1189 | 1190 | self.lg("", log_to_ha=False) 1191 | 1192 | def print_collection( 1193 | self, key: str, collection: Iterable[Any], indentation: int = 0 1194 | ) -> None: 1195 | 1196 | self.lg(f"{indentation * ' '}{key}:", log_to_ha=False) 1197 | indentation = indentation + 2 1198 | 1199 | for item in collection: 1200 | indent = indentation * " " 1201 | 1202 | if isinstance(item, dict): 1203 | 1204 | if "name" in item: 1205 | self.print_collection(item.pop("name", ""), item, indentation) 1206 | else: 1207 | self.lg( 1208 | f"{indent}{hl(pformat(item, compact=True))}", log_to_ha=False 1209 | ) 1210 | 1211 | elif isinstance(collection, dict): 1212 | 1213 | if isinstance(collection[item], set): 1214 | self.print_collection(item, collection[item], indentation) 1215 | else: 1216 | self._print_cfg_setting(item, collection[item], indentation) 1217 | 1218 | else: 1219 | self.lg(f"{indent}· {hl(item)}", log_to_ha=False) 1220 | 1221 | def _print_cfg_setting(self, key: str, value: int | str, indentation: int) -> None: 1222 | unit = prefix = "" 1223 | indent = indentation * " " 1224 | 1225 | # legacy way 1226 | if key == "delay" and isinstance(value, int): 1227 | unit = "min" 1228 | min_value = f"{int(value / 60)}:{int(value % 60):02d}" 1229 | self.lg( 1230 | f"{indent}{key}: {prefix}{hl(min_value)}{unit} ≈ " f"{hl(value)}sec", 1231 | ascii_encode=False, 1232 | log_to_ha=False, 1233 | ) 1234 | 1235 | else: 1236 | if "_units" in self.config and key in self.config["_units"]: 1237 | unit = self.config["_units"][key] 1238 | if "_prefixes" in self.config and key in self.config["_prefixes"]: 1239 | prefix = self.config["_prefixes"][key] 1240 | 1241 | self.lg(f"{indent}{key}: {prefix}{hl(value)}{unit}", log_to_ha=False) 1242 | -------------------------------------------------------------------------------- /demo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | ARG APPDAEMON_VERSION=dev 4 | 5 | # api / admin ui 6 | EXPOSE 5050 7 | 8 | # appdaemon mounts 9 | VOLUME /conf 10 | VOLUME /certs 11 | 12 | WORKDIR /usr/src/app 13 | 14 | # install additional packages 15 | RUN apk add --no-cache curl tzdata 16 | 17 | # install appdaemon from source 18 | RUN apk add --virtual build cargo git gcc g++ libffi-dev make openssl-dev musl-dev && \ 19 | git clone https://github.com/AppDaemon/appdaemon.git /usr/src/app && cd /usr/src/app && git checkout ${APPDAEMON_VERSION} && \ 20 | pip install --no-cache-dir . && \ 21 | apk del build 22 | # # pip: install appdaemon from pypi 23 | # RUN pip install --no-cache-dir appdaemon==${APPDAEMON_VERSION} 24 | 25 | # bootstrap appdaemon 26 | RUN mkdir -p conf/apps/ 27 | # add patched default configuration 28 | COPY demo/automoli.apps.yaml conf/apps/apps.yaml.example 29 | COPY demo/automoli.appdaemon.yaml conf/appdaemon.yaml.example 30 | 31 | # add AutoMoLi 32 | COPY apps/automoli conf/apps/automoli 33 | 34 | # # pip: get start script missing in pip package from repo 35 | # RUN wget https://raw.githubusercontent.com/AppDaemon/appdaemon/${APPDAEMON_VERSION}/dockerStart.sh && chmod +x /usr/src/app/dockerStart.sh 36 | 37 | # patch appdaemon startup script with new env vars & show config on startup 38 | RUN sed -i '44i\\n# AutoMoLi: env variable configuration\nif [ -n "$AUTOMOLI_ROOM" ]; then sed -i \"s/kitchen/$(echo $AUTOMOLI_ROOM | sed "s/[^[:print:]]//g")/\" $CONF/apps/apps.yaml; fi' /usr/src/app/dockerStart.sh && \ 39 | sed -i '47i# AutoMoLi: show config on startup\necho -e "\\n\\n\\033[1mAutoMoLil\\033[0m configuration in \\033[1m$CONF/apps/apps.yaml\\033[0m:\\n" && cat $CONF/apps/apps.yaml && echo -e "\\n\\n"' /usr/src/app/dockerStart.sh 40 | 41 | # start script 42 | RUN chmod +x /usr/src/app/dockerStart.sh 43 | ENTRYPOINT ["./dockerStart.sh"] 44 | -------------------------------------------------------------------------------- /demo/automoli.appdaemon.yaml: -------------------------------------------------------------------------------- 1 | appdaemon: 2 | latitude: 13.37 3 | longitude: 13.37 4 | time_zone: "Europe/Amsterdam" 5 | elevation: 1337 6 | plugins: 7 | HASS: 8 | type: hass 9 | ha_url: 10 | token: 11 | http: 12 | url: http://127.0.0.1:5050 13 | admin: 14 | api: 15 | hadashboard: 16 | -------------------------------------------------------------------------------- /demo/automoli.apps.yaml: -------------------------------------------------------------------------------- 1 | kitchen: 2 | module: automoli 3 | class: AutoMoLi 4 | room: kitchen 5 | delay: 120 6 | daytimes: 7 | - {starttime: "05:30", name: morning, light: 40} 8 | - {starttime: "07:15", name: day, light: 100} 9 | - {starttime: "19:30", name: evening, light: 70} 10 | - {starttime: "22:00", name: night, light: 10} 11 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "💡 AutoMoLi - Auto Motion Lights", 3 | "country": "DE", 4 | "render_readme": "true" 5 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ["py39"] 3 | 4 | [tool.isort] 5 | # https://github.com/PyCQA/isort/wiki/isort-Settings 6 | profile = "black" 7 | # will group `import x` and `from x import` of the same module. 8 | combine_as_imports = true 9 | force_sort_within_sections = true 10 | forced_separate = [ 11 | "tests", 12 | ] 13 | known_first_party = [ 14 | "hassapi", 15 | "appdaemon", 16 | ] 17 | 18 | [tool.pylint.MASTER] 19 | jobs = 4 20 | load-plugins = [ 21 | "pylint.extensions.code_style", 22 | "pylint.extensions.typing", 23 | ] 24 | persistent = false 25 | 26 | [tool.pylint."MESSAGES CONTROL"] 27 | disable = [ 28 | "attribute-defined-outside-init", 29 | "import-outside-toplevel", 30 | "wrong-import-position", 31 | ] 32 | 33 | [tool.pylint.BASIC] 34 | class-const-naming-style = "any" 35 | good-names = [ 36 | "_", 37 | "ev", 38 | "ex", 39 | "fp", 40 | "i", 41 | "id", 42 | "j", 43 | "k", 44 | "Run", 45 | "T", 46 | ] 47 | 48 | [tool.pylint.REPORTS] 49 | score = true 50 | 51 | [tool.pylint.TYPECHECK] 52 | 53 | [tool.pylint.FORMAT] 54 | expected-line-ending-format = "LF" 55 | 56 | [tool.pylint.TYPING] 57 | py-version = "3.9" 58 | runtime-typing = false 59 | --------------------------------------------------------------------------------