├── .eslintignore ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .vscode └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bmc-button.svg ├── dist └── automations.js ├── loadExtension.ps1 ├── loadExtension.sh ├── matterbridge.svg ├── package-lock.json ├── package.json ├── src └── automations.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "overrides": [], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint" 18 | ], 19 | "rules": {} 20 | } -------------------------------------------------------------------------------- /.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 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.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 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore compiled code 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start", 7 | "problemMatcher": [ 8 | "$tsc-watch" 9 | ], 10 | "label": "npm: start", 11 | "detail": "tsc --watch", 12 | "isBackground": true 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Matterbridge Logo   zigbee2mqtt-automations changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | If you like this project and find it useful, please consider giving it a star on GitHub at https://github.com/Luligu/matterbridge-zigbee2mqtt and sponsoring it. 6 | 7 | 8 | Buy me a coffee 9 | 10 | 11 | ## [2.0.5] - 2025-06-06 12 | 13 | ### Fixed 14 | 15 | - [extension]: In the release 2.4.0 commit: bdb94da46e0461337f4a61b4f2a6bfa5172f608f of zigbee2mqtt, the code changed again. This fix again using a different approach. Thanks https://github.com/robvanoostenrijk for your contribute. 16 | 17 | 18 | Buy me a coffee 19 | 20 | 21 | ## [2.0.4] - 2025-06-04 22 | 23 | ### Fixed 24 | 25 | - [extension]: In the release 2.4.0 commit: bdb94da46e0461337f4a61b4f2a6bfa5172f608f of zigbee2mqtt, the code changed again. This fix again. 26 | 27 | 28 | Buy me a coffee 29 | 30 | 31 | ## [2.0.3] - 2025-06-03 32 | 33 | ### Fixed 34 | 35 | - [extension]: In the new release on zigbee2mqtt, the external extensions are now loaded from a temp directory. We use require to load the needed packages (yaml and data) from where we know they are. 36 | 37 | 38 | Buy me a coffee 39 | 40 | 41 | ## [2.0.2] - 2025-04-19 42 | 43 | ### Fixed 44 | 45 | - [suncalc]: Fixed the daily reloading of suncalc times. 46 | 47 | 48 | Buy me a coffee 49 | 50 | 51 | ## [2.0.1] - 2025-03-19 52 | 53 | ### Added 54 | 55 | - [typo]: Added the possibility to use turn_off_after with a specific payload_off. https://github.com/Luligu/zigbee2mqtt-automations/issues/16. 56 | - [examples]: Added the example "Motion in the hallway with custom payload_off" to use turn_off_after with a specific payload_off. 57 | - [examples]: Added the example "Configure daily". 58 | 59 | ### Fixed 60 | 61 | - [logger]: The logger warning level is now warning and not warn. 62 | - [typo]: Fixed a typo: https://github.com/Luligu/zigbee2mqtt-automations/pull/15. Thanks https://github.com/robvanoostenrijk. 63 | 64 | 65 | Buy me a coffee 66 | 67 | 68 | ## [2.0.0] - 2025-01-04 69 | 70 | ### Added 71 | 72 | - [extension]: The extension signature has been updated in zigbee2MQTT 2.0.0. The PR https://github.com/Luligu/zigbee2mqtt-automations/pull/8 addressing the update has been merged. Many thanks to https://github.com/robvanoostenrijk for his contribution. 73 | 74 | 75 | Buy me a coffee 76 | 77 | 78 | ## [1.0.10] - 2024-11-29 79 | 80 | ### Added 81 | 82 | - [mqtt trigger]: Merged PR https://github.com/Luligu/zigbee2mqtt-automations/pull/7 (thanks https://github.com/robvanoostenrijk) 83 | 84 | 85 | Buy me a coffee 86 | 87 | 88 | ## [1.0.9] - 2024-11-29 89 | 90 | ### Fixed 91 | 92 | - [suncalc automations]: Fix conversion in toLocaleTimeString(). 93 | 94 | 95 | Buy me a coffee 96 | 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Luligu (https://github.com/Luligu) 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Matterbridge Logo    zigbee2mqtt-automations 2 | Automations extension for zigbee2mqtt (www.zigbee2mqtt.io) 3 | 4 | Features: 5 | - Support multiple event based triggers; 6 | - Support time automations (execution at specified time); 7 | - Support suncalc automations like sunset, sunrise and others at a specified location and altitude; 8 | - Provides comprehensive logging within the zigbee2mqtt logging system for triggers, conditions and actions; 9 | - Performs thorough validation of the automation configuration file for errors (errors are logged at loading time and the erroneous automation is discarded); 10 | - Error messages and execution notifications can be displayed as pop-up messages in frontend. 11 | - User can filter automation events in the frontend by entering [Automations] in the 'Filter by text' field. 12 | 13 | If you like this project and find it useful, please consider giving it a star on GitHub at https://github.com/Luligu/zigbee2mqtt-automations and sponsoring it. 14 | 15 | 16 | Buy me a coffee 17 | 18 | 19 | Check also the https://github.com/Luligu/matterbridge-zigbee2mqtt matterbridge zigbee2mqtt plugin. 20 | 21 | 22 | Matterbridge Zigbee2MQTT Plugin 23 | 24 | 25 | # What is an automation 26 | An automation typically consists of one or more triggers and executes one or more actions. 27 | Optionally, it can also include one or more conditions. 28 | Any trigger can start the automation while conditions must all be true for the automation to run. 29 | 30 | # How to install 31 | Create an automations.yaml file in the zigbee2mqtt\data directory (alongside configuration.yaml) and write your first automation (copy from the examples). 32 | Don't modify configuration.yaml. 33 | 34 | Method 1 35 | Download the file dist\automation.js and place it in the zigbee2mqtt\data\extension directory (create the directory if it doesn't exist). 36 | Stop zigbee2mqtt, ensure it has completely stoppped, and then start it again. This method ensures all extensions are loaded. 37 | 38 | Method 2 39 | In frontend go to Extensions add an extension. Name it automation.js and confirm. In the editor delete the default extension content and copy paste the entire content of automation.js. Save it. 40 | 41 | # How to reload the automations when the file automations.yaml has been modified. 42 | In frontend go to Extensions. Select automation.js and save. The extension is reloaded and the automations.yaml is reloaded too. 43 | 44 | # Config file automations.yaml: 45 | 46 | ``` 47 | : 48 | active?: ## Values: true or false Default: true (true: the automation is active) 49 | execute_once?: ## Values: true or false Default: false (true: the automatione is executed only once) 50 | trigger: 51 | ---------------------- time trigger ------------------------------ 52 | time: ## Values: time string hh:mm:ss or any suncalc sunrise, sunset ... 53 | latitude?: ## Numeric latitude (mandatory for suncalc triggers) Use https://www.latlong.net/ to get latidute and longitude based on your adress 54 | longitude?: ## Numeric longitude (mandatory for suncalc triggers) Use https://www.latlong.net/ to get latidute and longitude based on your adress 55 | elevation?: ## Numeric elevation in meters for precise suncalc results Default: 0 56 | ---------------------- event trigger ------------------------------ 57 | entity: ## Name of the entity (device or group friendly name) to evaluate 58 | for?: ## Number: duration in seconds for the specific attribute to remain in the triggered state 59 | state: ## Values: ON OFF 60 | attribute: ## Name of the attribute to evaluate (example: state, brightness, illuminance_lux, occupancy) 61 | equal?: ## Value of the attribute to evaluate with = 62 | not_equal?: ## Value of the attribute to evaluate with != 63 | above?: ## Numeric value of the attribute to evaluate with > 64 | below?: ## Numeric value of the attribute to evaluate with < 65 | action: ## Value of the action to evaluate e.g. single, double, hold ... 66 | condition?: 67 | ---------------------- event condition ------------------------------ 68 | entity: ## Name of the entity (device or group friendly name) to evaluate 69 | state?: ## Values: ON OFF 70 | attribute?: ## Name of the attribute (example: state, brightness, illuminance_lux, occupancy) 71 | equal?: ## Value of the attribute to evaluate with = 72 | above?: ## Numeric value of attribute to evaluate with > 73 | below?: ## Numeric value of attribute to evaluate with < 74 | ---------------------- time condition ------------------------------ 75 | after?: ## Time string hh:mm:ss 76 | before?: ## Time string hh:mm:ss 77 | between?: ## Time range string hh:mm:ss-hh:mm:ss 78 | weekday?: ## Day string or array of day strings: 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' 79 | action: 80 | entity: ## Name of the entity (device or group friendly name) to send the payload to 81 | payload: ## Values: turn_on, turn_off, toggle or any supported attributes in an object or indented on the next rows 82 | (example: { state: OFF, brightness: 254, color: { r: 0, g: 255, b: 0 } }) 83 | logger?: ## Values: debug info warning error. Default: debug. The action will be logged on z2m logger with the specified logging level 84 | turn_off_after?: ## Number: seconds to wait before turning off entity. Will send a turn_off to the entity. 85 | payload_off?: ## Values: any supported attributes in an object. Will use payload_off instead of { state: "OFF" }. 86 | ``` 87 | 88 | # Trigger examples: 89 | 90 | ### The automation is run at the specified time 91 | ```yaml 92 | Turn off at 23: 93 | trigger: 94 | time: 23:00:00 95 | ``` 96 | 97 | ### The automation is run at sunset time at the coordinates and elevation specified 98 | ```yaml 99 | Sunset: 100 | trigger: 101 | time: sunset 102 | latitude: 48.858372 103 | longitude: 2.294481 104 | elevation: 330 105 | ``` 106 | 107 | ### The automation is run when contact change to false (the contact is opened) for the device Contact sensor 108 | ```yaml 109 | Contact sensor OPENED: 110 | trigger: 111 | entity: Contact sensor 112 | attribute: contact 113 | equal: false 114 | ``` 115 | 116 | 117 | # Time condition examples: 118 | 119 | ### The automation is run only on monday, tuesday and friday and only after 08:30 and before 22:30 120 | ```yaml 121 | condition: 122 | after: 08:30:00 123 | before: 22:30:00 124 | weekday: ['mon', 'tue', 'fri'] 125 | ``` 126 | 127 | ### The automation is run only between 08:00 and 20:00 128 | ```yaml 129 | condition: 130 | between: 08:00:00-20:00:00 131 | ``` 132 | 133 | ### The automation is run only after 20:00 and before 08:00 134 | ```yaml 135 | condition: 136 | between: 20:00:00-08:00:00 137 | ``` 138 | 139 | # Event condition examples: 140 | 141 | ### The automation is run only if 'At home' is ON 142 | ```yaml 143 | condition: 144 | entity: At home 145 | state: ON 146 | ``` 147 | 148 | ### The automation is run only if the illuminance_lux attribute of 'Light sensor' is below 100. 149 | ```yaml 150 | condition: 151 | entity: Light sensor 152 | attribute: illuminance_lux 153 | below: 100 154 | ``` 155 | 156 | ### The automation is run only if 'At home' is ON and 'Is night' is OFF. For multiple entity conditions entity must be indented. 157 | ```yaml 158 | condition: 159 | - entity: At home 160 | state: ON 161 | - entity: Is night 162 | state: OFF 163 | ``` 164 | 165 | # Time and event condition examples: 166 | 167 | ### For multiple conditions after, before, weekday and entity must be indented 168 | ```yaml 169 | condition: 170 | - after: 08:00:00 171 | - before: 22:30:00 172 | - weekday: ['mon', 'tue', 'thu', 'fri'] 173 | - entity: Is night 174 | state: ON 175 | - entity: Is dark 176 | state: ON 177 | ``` 178 | 179 | # Action examples: 180 | 181 | ### Payload can be a string (turn_on, turn_off and toggle or an object) 182 | ```yaml 183 | action: 184 | - entity: Miboxer RGB led controller 185 | payload: { brightness: 255, color: { r: 0, g: 0, b: 255 }, transition: 5 } 186 | - entity: Moes RGB CCT led controller 187 | payload: { brightness: 255, color_temp: 500, transition: 10 } 188 | - entity: Aqara switch T1 189 | payload: turn_on 190 | turn_off_after: 10 191 | - entity: Moes switch double 192 | payload: { state_l1: ON } 193 | ``` 194 | 195 | ### Instead of specify an object it's possible to indent each attribute 196 | ```yaml 197 | action: 198 | - entity: Moes switch double 199 | payload: 200 | state_l1: ON 201 | ``` 202 | 203 | # Complete automation examples 204 | 205 | ### If there was a zigbee2mqtt installation in the top of the Eiffel Tower this would be the perfect automation. 206 | ```yaml 207 | Sunrise: 208 | trigger: 209 | time: sunrise 210 | latitude: 48.858372 211 | longitude: 2.294481 212 | elevation: 330 213 | action: 214 | - entity: Moes RGB CCT led controller 215 | payload: { state: OFF } 216 | - entity: Is night 217 | payload: { state: OFF } 218 | ``` 219 | 220 | ```yaml 221 | Sunset: 222 | trigger: 223 | time: sunset 224 | latitude: 48.858372 225 | longitude: 2.294481 226 | elevation: 330 227 | action: 228 | - entity: Moes RGB CCT led controller 229 | payload: { state: ON } 230 | - entity: Is night 231 | payload: { state: ON } 232 | ``` 233 | 234 | 235 | ### These automations turn on and off the group 'Is dark' based on the light mesured by a common light sensor for 60 secs (so there is not false reading) 236 | ```yaml 237 | Light sensor below 50lux for 60s: 238 | trigger: 239 | entity: Light sensor 240 | attribute: illuminance_lux 241 | below: 50 242 | for: 60 243 | action: 244 | entity: Is dark 245 | payload: turn_on 246 | ``` 247 | 248 | ```yaml 249 | Light sensor above 60lux for 60s: 250 | trigger: 251 | entity: Light sensor 252 | attribute: illuminance_lux 253 | above: 60 254 | for: 60 255 | action: 256 | entity: Is dark 257 | payload: turn_off 258 | ``` 259 | 260 | ### These automations turn on and off the device 'Aqara switch T1' 261 | ```yaml 262 | Contact sensor OPENED: 263 | trigger: 264 | entity: Contact sensor 265 | attribute: contact 266 | equal: false 267 | condition: 268 | entity: At home 269 | state: ON 270 | action: 271 | entity: Aqara switch T1 272 | logger: info 273 | payload: turn_on 274 | ``` 275 | 276 | ```yaml 277 | Contact sensor CLOSED: 278 | trigger: 279 | entity: Contact sensor 280 | attribute: contact 281 | state: true 282 | for: 5 283 | action: 284 | entity: Aqara switch T1 285 | logger: info 286 | payload: 287 | state: OFF 288 | ``` 289 | 290 | ### Turn on the light for 60 secs after occupancy is detected by 'Motion sensor' 291 | ```yaml 292 | Motion in the hallway: 293 | active: true 294 | trigger: 295 | entity: Hallway motion sensor 296 | attribute: occupancy 297 | equal: true 298 | action: 299 | entity: Hallway light 300 | payload: turn_on 301 | turn_off_after: 60 302 | logger: info 303 | ``` 304 | 305 | 306 | ### Turn on the light for 60 secs after occupancy is detected by 'Hallway motion sensor'. Configure the payload_off to send. 307 | ```yaml 308 | Motion in the hallway with custom payload_off: 309 | active: true 310 | trigger: 311 | entity: Hallway motion sensor 312 | attribute: occupancy 313 | equal: true 314 | action: 315 | entity: Hallway light 316 | payload: turn_on 317 | turn_off_after: 60 318 | payload_off: { state: "OFF" } 319 | logger: info 320 | ``` 321 | 322 | 323 | ### Creates a daily routine to configure any devices that sometimes loose the correct setting. 324 | ```yaml 325 | Configure daily: 326 | active: true 327 | trigger: 328 | time: 20:00:00 329 | action: 330 | - entity: Bathroom Lights 331 | payload: { switch_type: "momentary" } 332 | logger: info 333 | - entity: Bathroom Leds 334 | payload: { switch_type: "momentary" } 335 | logger: info 336 | ``` 337 | 338 | 339 | # Sponsor 340 | If you like the extension and want to sponsor it: 341 | - https://www.paypal.com/paypalme/LuliguGitHub 342 | - https://www.buymeacoffee.com/luligugithub 343 | 344 | 345 | Buy me a coffee 346 | 347 | 348 | # Bug report and feature request 349 | https://github.com/Luligu/zigbee2mqtt-automations/issues 350 | 351 | # Credits 352 | Sun calculations are derived entirely from suncalc package https://www.npmjs.com/package/suncalc. 353 | This extension was originally forked from https://github.com/Anonym-tsk/zigbee2mqtt-extensions. 354 | -------------------------------------------------------------------------------- /bmc-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /dist/automations.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /** 3 | * This file contains the class AutomationsExtension and its definitions. 4 | * 5 | * @file automations.ts 6 | * @author Luligu (https://github.com/Luligu) 7 | * @copyright 2023, 2024, 2025 Luligu 8 | * @date 2023-10-15 9 | * 10 | * See LICENSE in the root. 11 | * 12 | */ 13 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 14 | /* eslint-disable @typescript-eslint/no-var-requires */ 15 | const buffer_1 = require("buffer"); 16 | // These packages are defined inside zigbee2mqtt and so they are not available here to import! 17 | // The external extensions are now loaded from a temp directory, we use require to load them from where we know they are 18 | const path = require("node:path"); 19 | const utilPath = path.join(require.main?.path, "dist", "util"); 20 | const joinPath = require(path.join(utilPath, "data")).default.joinPath; 21 | const readIfExists = require(path.join(utilPath, "yaml")).default.readIfExists; 22 | function toArray(item) { 23 | return Array.isArray(item) ? item : [item]; 24 | } 25 | var ConfigSunCalc; 26 | (function (ConfigSunCalc) { 27 | ConfigSunCalc["SOLAR_NOON"] = "solarNoon"; 28 | ConfigSunCalc["NADIR"] = "nadir"; 29 | ConfigSunCalc["SUNRISE"] = "sunrise"; 30 | ConfigSunCalc["SUNSET"] = "sunset"; 31 | ConfigSunCalc["SUNRISE_END"] = "sunriseEnd"; 32 | ConfigSunCalc["SUNSET_START"] = "sunsetStart"; 33 | ConfigSunCalc["DAWN"] = "dawn"; 34 | ConfigSunCalc["DUSK"] = "dusk"; 35 | ConfigSunCalc["NAUTICAL_DAWN"] = "nauticalDawn"; 36 | ConfigSunCalc["NAUTICAL_DUSK"] = "nauticalDusk"; 37 | ConfigSunCalc["NIGHT_END"] = "nightEnd"; 38 | ConfigSunCalc["NIGHT"] = "night"; 39 | ConfigSunCalc["GOLDEN_HOUR_END"] = "goldenHourEnd"; 40 | ConfigSunCalc["GOLDEN_HOUR"] = "goldenHour"; 41 | })(ConfigSunCalc || (ConfigSunCalc = {})); 42 | var ConfigState; 43 | (function (ConfigState) { 44 | ConfigState["ON"] = "ON"; 45 | ConfigState["OFF"] = "OFF"; 46 | ConfigState["TOGGLE"] = "TOGGLE"; 47 | })(ConfigState || (ConfigState = {})); 48 | var ConfigPayload; 49 | (function (ConfigPayload) { 50 | ConfigPayload["TOGGLE"] = "toggle"; 51 | ConfigPayload["TURN_ON"] = "turn_on"; 52 | ConfigPayload["TURN_OFF"] = "turn_off"; 53 | })(ConfigPayload || (ConfigPayload = {})); 54 | var MessagePayload; 55 | (function (MessagePayload) { 56 | MessagePayload["EXECUTE"] = "execute"; 57 | })(MessagePayload || (MessagePayload = {})); 58 | class InternalLogger { 59 | constructor() { } 60 | debug(message, ...args) { 61 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;247m${message}\x1b[0m`, ...args); 62 | } 63 | warning(message, ...args) { 64 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;220m${message}\x1b[0m`, ...args); 65 | } 66 | info(message, ...args) { 67 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;255m${message}\x1b[0m`, ...args); 68 | } 69 | error(message, ...args) { 70 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;9m${message}\x1b[0m`, ...args); 71 | } 72 | } 73 | class AutomationsExtension { 74 | constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, logger) { 75 | this.zigbee = zigbee; 76 | this.mqtt = mqtt; 77 | this.state = state; 78 | this.publishEntityState = publishEntityState; 79 | this.eventBus = eventBus; 80 | this.enableDisableExtension = enableDisableExtension; 81 | this.restartCallback = restartCallback; 82 | this.addExtension = addExtension; 83 | this.settings = settings; 84 | this.logger = logger; 85 | this.eventAutomations = {}; 86 | this.timeAutomations = {}; 87 | this.log = new InternalLogger(); 88 | this.mqttBaseTopic = settings.get().mqtt.base_topic; 89 | this.triggerForTimeouts = {}; 90 | this.turnOffAfterTimeouts = {}; 91 | this.automationsTopic = 'zigbee2mqtt-automations'; 92 | // eslint-disable-next-line no-useless-escape 93 | this.topicRegex = new RegExp(`^${this.automationsTopic}\/(.*)`); 94 | this.logger.info(`[Automations] Loading automation.js`); 95 | if (!this.parseConfig()) 96 | return; 97 | /* 98 | this.log.info(`Event automation:`); 99 | Object.keys(this.eventAutomations).forEach(key => { 100 | const eventAutomationArray = this.eventAutomations[key]; 101 | eventAutomationArray.forEach(eventAutomation => { 102 | this.log.info(`- key: #${key}# automation: ${this.stringify(eventAutomation, true)}`); 103 | }); 104 | }); 105 | */ 106 | //this.log.info(`Time automation:`); 107 | Object.keys(this.timeAutomations).forEach(key => { 108 | const timeAutomationArray = this.timeAutomations[key]; 109 | timeAutomationArray.forEach(timeAutomation => { 110 | //this.log.info(`- key: #${key}# automation: ${this.stringify(timeAutomation, true)}`); 111 | this.startTimeTriggers(key, timeAutomation); 112 | }); 113 | }); 114 | this.startMidnightTimeout(); 115 | this.logger.info(`[Automations] Automation.js loaded`); 116 | } 117 | parseConfig() { 118 | let configAutomations = {}; 119 | try { 120 | // configAutomations = (yaml.readIfExists(data.joinPath('automations.yaml')) || {}) as ConfigAutomations; 121 | configAutomations = (readIfExists(joinPath('automations.yaml')) || {}); 122 | } 123 | catch (error) { 124 | this.logger.error(`[Automations] Error loading file automations.yaml: see stderr for explanation`); 125 | console.log(error); 126 | return false; 127 | } 128 | Object.entries(configAutomations).forEach(([key, configAutomation]) => { 129 | const actions = toArray(configAutomation.action); 130 | const conditions = configAutomation.condition ? toArray(configAutomation.condition) : []; 131 | const triggers = toArray(configAutomation.trigger); 132 | // Check automation 133 | if (configAutomation.active === false) { 134 | this.logger.info(`[Automations] Automation [${key}] not registered since active is false`); 135 | return; 136 | } 137 | if (!configAutomation.trigger) { 138 | this.logger.error(`[Automations] Config validation error for [${key}]: no triggers defined`); 139 | return; 140 | } 141 | if (!configAutomation.action) { 142 | this.logger.error(`[Automations] Config validation error for [${key}]: no actions defined`); 143 | return; 144 | } 145 | // Check triggers 146 | for (const trigger of triggers) { 147 | if (!trigger.time && !trigger.entity) { 148 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity not defined`); 149 | return; 150 | } 151 | if (!trigger.time && !this.zigbee.resolveEntity(trigger.entity)) { 152 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${trigger.entity}# not found`); 153 | return; 154 | } 155 | } 156 | // Check actions 157 | for (const action of actions) { 158 | if (!action.entity) { 159 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity not defined`); 160 | return; 161 | } 162 | if (!this.zigbee.resolveEntity(action.entity)) { 163 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity #${action.entity}# not found`); 164 | return; 165 | } 166 | if (!action.payload) { 167 | this.logger.error(`[Automations] Config validation error for [${key}]: action payload not defined`); 168 | return; 169 | } 170 | } 171 | // Check conditions 172 | for (const condition of conditions) { 173 | if (!condition.entity && !condition.after && !condition.before && !condition.between && !condition.weekday) { 174 | this.logger.error(`[Automations] Config validation error for [${key}]: condition unknown`); 175 | return; 176 | } 177 | if (condition.entity && !this.zigbee.resolveEntity(condition.entity)) { 178 | this.logger.error(`[Automations] Config validation error for [${key}]: condition entity #${condition.entity}# not found`); 179 | return; 180 | } 181 | } 182 | for (const trigger of triggers) { 183 | if (trigger.time !== undefined) { 184 | const timeTrigger = trigger; 185 | this.logger.info(`[Automations] Registering time automation [${key}] trigger: ${timeTrigger.time}`); 186 | const suncalcs = Object.values(ConfigSunCalc); 187 | if (suncalcs.includes(timeTrigger.time)) { 188 | if (!timeTrigger.latitude || !timeTrigger.longitude) { 189 | this.logger.error(`[Automations] Config validation error for [${key}]: latitude and longitude are mandatory for ${trigger.time}`); 190 | return; 191 | } 192 | const suncalc = new SunCalc(); 193 | const times = suncalc.getTimes(new Date(), timeTrigger.latitude, timeTrigger.longitude, timeTrigger.elevation ? timeTrigger.elevation : 0); 194 | this.logger.debug(`[Automations] Sunrise at ${times[ConfigSunCalc.SUNRISE].toLocaleTimeString()} sunset at ${times[ConfigSunCalc.SUNSET].toLocaleTimeString()} for latitude:${timeTrigger.latitude} longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0}`); 195 | this.log.debug(`[Automations] For latitude:${timeTrigger.latitude} longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0} suncalc are:\n`, times); 196 | const options = { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }; 197 | const time = times[trigger.time].toLocaleTimeString('en-GB', options); 198 | this.log.debug(`[Automations] Registering time automation [${key}] trigger: ${time}`); 199 | if (!this.timeAutomations[time]) 200 | this.timeAutomations[time] = []; 201 | this.timeAutomations[time].push({ name: key, execute_once: configAutomation.execute_once, trigger: timeTrigger, action: actions, condition: conditions }); 202 | } 203 | else if (this.matchTimeString(timeTrigger.time)) { 204 | if (!this.timeAutomations[timeTrigger.time]) 205 | this.timeAutomations[timeTrigger.time] = []; 206 | this.timeAutomations[timeTrigger.time].push({ name: key, execute_once: configAutomation.execute_once, trigger: timeTrigger, action: actions, condition: conditions }); 207 | } 208 | else { 209 | this.logger.error(`[Automations] Config validation error for [${key}]: time syntax error for ${trigger.time}`); 210 | return; 211 | } 212 | } 213 | if (trigger.entity !== undefined) { 214 | const eventTrigger = trigger; 215 | if (!this.zigbee.resolveEntity(eventTrigger.entity)) { 216 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${eventTrigger.entity}# not found`); 217 | return; 218 | } 219 | this.logger.info(`[Automations] Registering event automation [${key}] trigger: entity #${eventTrigger.entity}#`); 220 | const entities = toArray(eventTrigger.entity); 221 | for (const entity of entities) { 222 | if (!this.eventAutomations[entity]) { 223 | this.eventAutomations[entity] = []; 224 | } 225 | this.eventAutomations[entity].push({ name: key, execute_once: configAutomation.execute_once, trigger: eventTrigger, action: actions, condition: conditions }); 226 | } 227 | } 228 | } // for (const trigger of triggers) 229 | }); 230 | return true; 231 | } 232 | /** 233 | * Check a time string and return a Date or undefined if error 234 | */ 235 | matchTimeString(timeString) { 236 | if (timeString.length !== 8) 237 | return undefined; 238 | const match = timeString.match(/(\d{2}):(\d{2}):(\d{2})/); 239 | if (match && parseInt(match[1], 10) <= 23 && parseInt(match[2], 10) <= 59 && parseInt(match[3], 10) <= 59) { 240 | const time = new Date(); 241 | time.setHours(parseInt(match[1], 10)); 242 | time.setMinutes(parseInt(match[2], 10)); 243 | time.setSeconds(parseInt(match[3], 10)); 244 | return time; 245 | } 246 | return undefined; 247 | } 248 | /** 249 | * Start a timeout in the first second of tomorrow date. 250 | * It also reload the suncalc times for the next day. 251 | * The timeout callback then will start the time triggers for tomorrow and start again a timeout for the next day. 252 | */ 253 | startMidnightTimeout() { 254 | const now = new Date(); 255 | const timeEvent = new Date(); 256 | timeEvent.setHours(23); 257 | timeEvent.setMinutes(59); 258 | timeEvent.setSeconds(59); 259 | this.logger.debug(`[Automations] Set timeout to reload for time automations`); 260 | this.midnightTimeout = setTimeout(() => { 261 | this.logger.info(`[Automations] Run timeout to reload time automations`); 262 | const newTimeAutomations = {}; 263 | const suncalcs = Object.values(ConfigSunCalc); 264 | Object.keys(this.timeAutomations).forEach(key => { 265 | const timeAutomationArray = this.timeAutomations[key]; 266 | timeAutomationArray.forEach(timeAutomation => { 267 | if (suncalcs.includes(timeAutomation.trigger.time)) { 268 | if (!timeAutomation.trigger.latitude || !timeAutomation.trigger.longitude) 269 | return; 270 | const suncalc = new SunCalc(); 271 | const times = suncalc.getTimes(new Date(), timeAutomation.trigger.latitude, timeAutomation.trigger.longitude, timeAutomation.trigger.elevation ?? 0); 272 | // this.log.info(`Key:[${key}] For latitude:${timeAutomation.trigger.latitude} longitude:${timeAutomation.trigger.longitude} elevation:${timeAutomation.trigger.elevation ?? 0} suncalcs are:\n`, times); 273 | const options = { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }; 274 | const time = times[timeAutomation.trigger.time].toLocaleTimeString('en-GB', options); 275 | // this.log.info(`Registering suncalc time automation at time [${time}] for:`, timeAutomation); 276 | this.logger.info(`[Automations] Registering suncalc time automation at time [${time}] for: ${this.stringify(timeAutomation)}`); 277 | if (!newTimeAutomations[time]) 278 | newTimeAutomations[time] = []; 279 | newTimeAutomations[time].push(timeAutomation); 280 | this.startTimeTriggers(time, timeAutomation); 281 | } 282 | else { 283 | // this.log.info(`Registering normal time automation at time [${key}] for:`, timeAutomation); 284 | this.logger.info(`[Automations] Registering normal time automation at time [${key}] for: ${this.stringify(timeAutomation)}`); 285 | if (!newTimeAutomations[key]) 286 | newTimeAutomations[key] = []; 287 | newTimeAutomations[key].push(timeAutomation); 288 | this.startTimeTriggers(key, timeAutomation); 289 | } 290 | }); 291 | }); 292 | this.timeAutomations = newTimeAutomations; 293 | this.startMidnightTimeout(); 294 | }, timeEvent.getTime() - now.getTime() + 2000); 295 | this.midnightTimeout.unref(); 296 | } 297 | /** 298 | * Take the key of TimeAutomations that is a string like hh:mm:ss, convert it in a Date object of today 299 | * and set the timer if not already passed for today. 300 | * The timeout callback then will run the automations 301 | */ 302 | startTimeTriggers(key, automation) { 303 | const now = new Date(); 304 | const timeEvent = this.matchTimeString(key); 305 | if (timeEvent !== undefined) { 306 | if (timeEvent.getTime() > now.getTime()) { 307 | this.logger.debug(`[Automations] Set timeout at ${timeEvent.toLocaleString()} for [${automation.name}]`); 308 | const timeout = setTimeout(() => { 309 | delete this.triggerForTimeouts[automation.name]; 310 | this.logger.debug(`[Automations] Timeout for [${automation.name}]`); 311 | this.runActionsWithConditions(automation, automation.condition, automation.action); 312 | }, timeEvent.getTime() - now.getTime()); 313 | timeout.unref(); 314 | this.triggerForTimeouts[automation.name] = timeout; 315 | } 316 | else { 317 | this.logger.debug(`[Automations] Timeout at ${timeEvent.toLocaleString()} is passed for [${automation.name}]`); 318 | } 319 | } 320 | else { 321 | this.logger.error(`[Automations] Timeout config error at ${key} for [${automation.name}]`); 322 | } 323 | } 324 | /** 325 | * null - return 326 | * false - return and stop timer 327 | * true - start the automation 328 | */ 329 | checkTrigger(automation, configTrigger, update, from, to) { 330 | let trigger; 331 | let attribute; 332 | let result; 333 | let actions; 334 | //this.log.warning(`[Automations] Trigger check [${automation.name}] update: ${this.stringify(update)} from: ${this.stringify(from)} to: ${this.stringify(to)}`); 335 | if (configTrigger.action !== undefined) { 336 | if (!Object.prototype.hasOwnProperty.call(update, 'action')) { 337 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no 'action' in update for #${configTrigger.entity}#`); 338 | return null; 339 | } 340 | trigger = configTrigger; 341 | actions = toArray(trigger.action); 342 | result = actions.includes(update.action); 343 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger is ${result} for #${configTrigger.entity}# action(s): ${this.stringify(actions)}`); 344 | return result; 345 | } 346 | else if (configTrigger.attribute !== undefined) { 347 | trigger = configTrigger; 348 | attribute = trigger.attribute; 349 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) { 350 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`); 351 | return null; 352 | } 353 | if (from[attribute] === to[attribute]) { 354 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`); 355 | return null; 356 | } 357 | if (typeof trigger.equal !== 'undefined' || typeof trigger.state !== 'undefined') { 358 | const value = trigger.state !== undefined ? trigger.state : trigger.equal; 359 | if (to[attribute] !== value) { 360 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${value} for #${configTrigger.entity}#`); 361 | return false; 362 | } 363 | if (from[attribute] === value) { 364 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already = ${value} for #${configTrigger.entity}#`); 365 | return null; 366 | } 367 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger equal/state ${value} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 368 | } 369 | if (typeof trigger.not_equal !== 'undefined') { 370 | if (to[attribute] === trigger.not_equal) { 371 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' = ${trigger.not_equal} for #${configTrigger.entity}#`); 372 | return false; 373 | } 374 | if (from[attribute] !== trigger.not_equal) { 375 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already != ${trigger.not_equal} for #${configTrigger.entity}#`); 376 | return null; 377 | } 378 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger not equal ${trigger.not_equal} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 379 | } 380 | if (typeof trigger.above !== 'undefined') { 381 | if (to[attribute] <= trigger.above) { 382 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' <= ${trigger.above} for #${configTrigger.entity}#`); 383 | return false; 384 | } 385 | if (from[attribute] > trigger.above) { 386 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already > ${trigger.above} for #${configTrigger.entity}#`); 387 | return null; 388 | } 389 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger above ${trigger.above} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 390 | } 391 | if (typeof trigger.below !== 'undefined') { 392 | if (to[attribute] >= trigger.below) { 393 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' >= ${trigger.below} for #${configTrigger.entity}#`); 394 | return false; 395 | } 396 | if (from[attribute] < trigger.below) { 397 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already < ${trigger.below} for #${configTrigger.entity}#`); 398 | return null; 399 | } 400 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger below ${trigger.below} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 401 | } 402 | return true; 403 | } 404 | else if (configTrigger.state !== undefined) { 405 | trigger = configTrigger; 406 | attribute = 'state'; 407 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) { 408 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`); 409 | return null; 410 | } 411 | if (from[attribute] === to[attribute]) { 412 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`); 413 | return null; 414 | } 415 | if (to[attribute] !== trigger.state) { 416 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${trigger.state} for #${configTrigger.entity}#`); 417 | return null; 418 | } 419 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger state ${trigger.state} is true for #${configTrigger.entity}# state is ${to[attribute]}`); 420 | return true; 421 | } 422 | return false; 423 | } 424 | checkCondition(automation, condition) { 425 | let timeResult = true; 426 | let eventResult = true; 427 | if (condition.after || condition.before || condition.between || condition.weekday) { 428 | timeResult = this.checkTimeCondition(automation, condition); 429 | } 430 | if (condition.entity) { 431 | eventResult = this.checkEntityCondition(automation, condition); 432 | } 433 | return (timeResult && eventResult); 434 | } 435 | // Return false if condition is false 436 | checkTimeCondition(automation, condition) { 437 | //this.logger.info(`[Automations] checkTimeCondition [${automation.name}]: ${this.stringify(condition)}`); 438 | const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; 439 | const now = new Date(); 440 | if (condition.weekday && !condition.weekday.includes(days[now.getDay()])) { 441 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for weekday: ${this.stringify(condition.weekday)} since today is ${days[now.getDay()]}`); 442 | return false; 443 | } 444 | if (condition.before) { 445 | const time = this.matchTimeString(condition.before); 446 | if (time !== undefined) { 447 | if (now.getTime() > time.getTime()) { 448 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for before: ${condition.before} since now is ${now.toLocaleTimeString()}`); 449 | return false; 450 | } 451 | } 452 | else { 453 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: before #${condition.before}# ignoring condition`); 454 | } 455 | } 456 | if (condition.after) { 457 | const time = this.matchTimeString(condition.after); 458 | if (time !== undefined) { 459 | if (now.getTime() < time.getTime()) { 460 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for after: ${condition.after} since now is ${now.toLocaleTimeString()}`); 461 | return false; 462 | } 463 | } 464 | else { 465 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: after #${condition.after}# ignoring condition`); 466 | } 467 | } 468 | if (condition.between) { 469 | const [startTimeStr, endTimeStr] = condition.between.split('-'); 470 | const startTime = this.matchTimeString(startTimeStr); 471 | const endTime = this.matchTimeString(endTimeStr); 472 | if (startTime !== undefined && endTime !== undefined) { 473 | // Internal time span: between: 08:00:00-20:00:00 474 | if (startTime.getTime() < endTime.getTime()) { 475 | if (now.getTime() < startTime.getTime() || now.getTime() > endTime.getTime()) { 476 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`); 477 | return false; 478 | } 479 | } 480 | // External time span: between: 20:00:00-06:00:00 481 | else if (startTime.getTime() > endTime.getTime()) { 482 | if (now.getTime() < startTime.getTime() && now.getTime() > endTime.getTime()) { 483 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`); 484 | return false; 485 | } 486 | } 487 | } 488 | else { 489 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: between #${condition.between}# ignoring condition`); 490 | } 491 | } 492 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is true for ${this.stringify(condition)}`); 493 | return true; 494 | } 495 | // Return false if condition is false 496 | checkEntityCondition(automation, condition) { 497 | if (!condition.entity) { 498 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: condition entity not specified`); 499 | return false; 500 | } 501 | const entity = this.zigbee.resolveEntity(condition.entity); 502 | if (!entity) { 503 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: entity #${condition.entity}# not found`); 504 | return false; 505 | } 506 | const attribute = condition.attribute || 'state'; 507 | const value = this.state.get(entity)[attribute]; 508 | if (condition.state !== undefined && value !== condition.state) { 509 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not '${condition.state}'`); 510 | return false; 511 | } 512 | if (condition.attribute !== undefined && condition.equal !== undefined && value !== condition.equal) { 513 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not equal '${condition.equal}'`); 514 | return false; 515 | } 516 | if (condition.attribute !== undefined && condition.below !== undefined && value >= condition.below) { 517 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not below '${condition.below}'`); 518 | return false; 519 | } 520 | if (condition.attribute !== undefined && condition.above !== undefined && value <= condition.above) { 521 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not above '${condition.above}'`); 522 | return false; 523 | } 524 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is true for entity #${condition.entity}# attribute '${attribute}' is '${value}'`); 525 | return true; 526 | } 527 | runActions(automation, actions) { 528 | for (const action of actions) { 529 | const entity = this.zigbee.resolveEntity(action.entity); 530 | if (!entity) { 531 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`); 532 | continue; 533 | } 534 | let data; 535 | //this.log.warn('Payload:', typeof action.payload, action.payload) 536 | if (typeof action.payload === 'string') { 537 | if (action.payload === ConfigPayload.TURN_ON) { 538 | data = { state: ConfigState.ON }; 539 | } 540 | else if (action.payload === ConfigPayload.TURN_OFF) { 541 | data = { state: ConfigState.OFF }; 542 | } 543 | else if (action.payload === ConfigPayload.TOGGLE) { 544 | data = { state: ConfigState.TOGGLE }; 545 | } 546 | else { 547 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`); 548 | return; 549 | } 550 | } 551 | else if (typeof action.payload === 'object') { 552 | data = action.payload; 553 | } 554 | else { 555 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`); 556 | return; 557 | } 558 | if (action.logger === 'info') 559 | this.logger.info(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 560 | else if (action.logger === 'warning') 561 | this.logger.warning(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 562 | else if (action.logger === 'error') 563 | this.logger.error(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 564 | else 565 | this.logger.debug(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 566 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, buffer_1.Buffer.from(this.payloadStringify(data))); 567 | if (action.turn_off_after) { 568 | this.startActionTurnOffTimeout(automation, action); 569 | } 570 | } // End for (const action of actions) 571 | if (automation.execute_once === true) { 572 | this.removeAutomation(automation.name); 573 | } 574 | } 575 | // Remove automation that has execute_once: true 576 | removeAutomation(name) { 577 | this.logger.debug(`[Automations] Uregistering automation [${name}]`); 578 | //this.log.warning(`Uregistering automation [${name}]`); 579 | Object.keys(this.eventAutomations).forEach((entity) => { 580 | //this.log.warning(`Entity: #${entity}#`); 581 | Object.values(this.eventAutomations[entity]).forEach((eventAutomation, index) => { 582 | if (eventAutomation.name === name) { 583 | //this.log.warning(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`); 584 | this.eventAutomations[entity].splice(index, 1); 585 | } 586 | else { 587 | //this.log.info(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`); 588 | } 589 | }); 590 | }); 591 | Object.keys(this.timeAutomations).forEach((now) => { 592 | //this.log.warning(`Time: #${now}#`); 593 | Object.values(this.timeAutomations[now]).forEach((timeAutomation, index) => { 594 | if (timeAutomation.name === name) { 595 | //this.log.warning(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`); 596 | this.timeAutomations[now].splice(index, 1); 597 | } 598 | else { 599 | //this.log.info(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`); 600 | } 601 | }); 602 | }); 603 | } 604 | // Stop the turn_off_after timeout 605 | stopActionTurnOffTimeout(automation, action) { 606 | const timeout = this.turnOffAfterTimeouts[automation.name + action.entity]; 607 | if (timeout) { 608 | this.logger.debug(`[Automations] Stop turn_off_after timeout for automation [${automation.name}]`); 609 | clearTimeout(timeout); 610 | delete this.turnOffAfterTimeouts[automation.name + action.entity]; 611 | } 612 | } 613 | // Start the turn_off_after timeout 614 | startActionTurnOffTimeout(automation, action) { 615 | this.stopActionTurnOffTimeout(automation, action); 616 | this.logger.debug(`[Automations] Start ${action.turn_off_after} seconds turn_off_after timeout for automation [${automation.name}]`); 617 | const timeout = setTimeout(() => { 618 | delete this.turnOffAfterTimeouts[automation.name + action.entity]; 619 | //this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}]`); 620 | const entity = this.zigbee.resolveEntity(action.entity); 621 | if (!entity) { 622 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`); 623 | this.stopActionTurnOffTimeout(automation, action); 624 | return; 625 | } 626 | const data = action.payload_off ?? { state: ConfigState.OFF }; 627 | if (action.logger === 'info') 628 | this.logger.info(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 629 | else if (action.logger === 'warning') 630 | this.logger.warning(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 631 | else if (action.logger === 'error') 632 | this.logger.error(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 633 | else 634 | this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 635 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, buffer_1.Buffer.from(this.payloadStringify(data))); 636 | }, action.turn_off_after * 1000); 637 | timeout.unref(); 638 | this.turnOffAfterTimeouts[automation.name + action.entity] = timeout; 639 | } 640 | runActionsWithConditions(automation, conditions, actions) { 641 | for (const condition of conditions) { 642 | //this.log.warning(`runActionsWithConditions: conditon: ${this.stringify(condition)}`); 643 | if (!this.checkCondition(automation, condition)) { 644 | return; 645 | } 646 | } 647 | this.runActions(automation, actions); 648 | } 649 | // Stop the trigger_for timeout 650 | stopTriggerForTimeout(automation) { 651 | const timeout = this.triggerForTimeouts[automation.name]; 652 | if (timeout) { 653 | //this.log.debug(`Stop timeout for automation [${automation.name}] trigger: ${this.stringify(automation.trigger)}`); 654 | this.logger.debug(`[Automations] Stop trigger-for timeout for automation [${automation.name}]`); 655 | clearTimeout(timeout); 656 | delete this.triggerForTimeouts[automation.name]; 657 | } 658 | } 659 | // Start the trigger_for timeout 660 | startTriggerForTimeout(automation) { 661 | if (automation.trigger.for === undefined || automation.trigger.for === 0) { 662 | this.logger.error(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout error for automation [${automation.name}]`); 663 | return; 664 | } 665 | this.logger.debug(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout for automation [${automation.name}]`); 666 | const timeout = setTimeout(() => { 667 | delete this.triggerForTimeouts[automation.name]; 668 | this.logger.debug(`[Automations] Trigger-for timeout for automation [${automation.name}]`); 669 | this.runActionsWithConditions(automation, automation.condition, automation.action); 670 | }, automation.trigger.for * 1000); 671 | timeout.unref(); 672 | this.triggerForTimeouts[automation.name] = timeout; 673 | } 674 | runAutomationIfMatches(automation, update, from, to) { 675 | const triggerResult = this.checkTrigger(automation, automation.trigger, update, from, to); 676 | if (triggerResult === false) { 677 | this.stopTriggerForTimeout(automation); 678 | return; 679 | } 680 | if (triggerResult === null) { 681 | return; 682 | } 683 | const timeout = this.triggerForTimeouts[automation.name]; 684 | if (timeout) { 685 | this.logger.debug(`[Automations] Waiting trigger-for timeout for automation [${automation.name}]`); 686 | return; 687 | } 688 | else { 689 | this.logger.debug(`[Automations] Start automation [${automation.name}]`); 690 | } 691 | if (automation.trigger.for) { 692 | this.startTriggerForTimeout(automation); 693 | return; 694 | } 695 | this.runActionsWithConditions(automation, automation.condition, automation.action); 696 | } 697 | findAndRun(entityId, update, from, to) { 698 | const automations = this.eventAutomations[entityId]; 699 | if (!automations) { 700 | return; 701 | } 702 | for (const automation of automations) { 703 | this.runAutomationIfMatches(automation, update, from, to); 704 | } 705 | } 706 | processMessage(message) { 707 | const match = message.topic.match(this.topicRegex); 708 | if (match) { 709 | for (const automations of Object.values(this.eventAutomations)) { 710 | for (const automation of automations) { 711 | if (automation.name == match[1]) { 712 | this.logger.info(`[Automations] MQTT message for [${match[1]}]: ${message.message}`); 713 | switch (message.message) { 714 | case MessagePayload.EXECUTE: 715 | this.runActions(automation, automation.action); 716 | break; 717 | } 718 | return; 719 | } 720 | } 721 | } 722 | } 723 | } 724 | async start() { 725 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 726 | this.eventBus.onStateChange(this, (data) => { 727 | this.findAndRun(data.entity.name, data.update, data.from, data.to); 728 | }); 729 | this.mqtt.subscribe(`${this.automationsTopic}/+`); 730 | this.eventBus.onMQTTMessage(this, (data) => { 731 | this.processMessage(data); 732 | }); 733 | } 734 | async stop() { 735 | this.logger.debug(`[Automations] Extension unloading`); 736 | for (const key of Object.keys(this.triggerForTimeouts)) { 737 | this.logger.debug(`[Automations] Clearing timeout ${key}`); 738 | clearTimeout(this.triggerForTimeouts[key]); 739 | delete this.triggerForTimeouts[key]; 740 | } 741 | for (const key of Object.keys(this.turnOffAfterTimeouts)) { 742 | this.logger.debug(`[Automations] Clearing timeout ${key}`); 743 | clearTimeout(this.turnOffAfterTimeouts[key]); 744 | delete this.turnOffAfterTimeouts[key]; 745 | } 746 | clearTimeout(this.midnightTimeout); 747 | this.logger.debug(`[Automations] Removing listeners`); 748 | this.eventBus.removeListeners(this); 749 | this.logger.debug(`[Automations] Extension unloaded`); 750 | } 751 | payloadStringify(payload) { 752 | return this.stringify(payload, false, 255, 255, 35, 220, 159, 1, '"', '"'); 753 | } 754 | stringify(payload, enableColors = false, colorPayload = 255, colorKey = 255, colorString = 35, colorNumber = 220, colorBoolean = 159, colorUndefined = 1, keyQuote = '', stringQuote = '\'') { 755 | const clr = (color) => { 756 | return enableColors ? `\x1b[38;5;${color}m` : ''; 757 | }; 758 | const reset = () => { 759 | return enableColors ? `\x1b[0m` : ''; 760 | }; 761 | const isArray = Array.isArray(payload); 762 | let string = `${reset()}${clr(colorPayload)}` + (isArray ? '[ ' : '{ '); 763 | Object.entries(payload).forEach(([key, value], index) => { 764 | if (index > 0) { 765 | string += ', '; 766 | } 767 | let newValue = ''; 768 | newValue = value; 769 | if (typeof newValue === 'string') { 770 | newValue = `${clr(colorString)}${stringQuote}${newValue}${stringQuote}${reset()}`; 771 | } 772 | if (typeof newValue === 'number') { 773 | newValue = `${clr(colorNumber)}${newValue}${reset()}`; 774 | } 775 | if (typeof newValue === 'boolean') { 776 | newValue = `${clr(colorBoolean)}${newValue}${reset()}`; 777 | } 778 | if (typeof newValue === 'undefined') { 779 | newValue = `${clr(colorUndefined)}undefined${reset()}`; 780 | } 781 | if (typeof newValue === 'object') { 782 | newValue = this.stringify(newValue, enableColors, colorPayload, colorKey, colorString, colorNumber, colorBoolean, colorUndefined, keyQuote, stringQuote); 783 | } 784 | // new 785 | if (isArray) 786 | string += `${newValue}`; 787 | else 788 | string += `${clr(colorKey)}${keyQuote}${key}${keyQuote}${reset()}: ${newValue}`; 789 | }); 790 | return string += ` ${clr(colorPayload)}` + (isArray ? ']' : '}') + `${reset()}`; 791 | } 792 | } 793 | /* 794 | FROM HERE IS THE COPY IN TS OF SUNCALC PACKAGE https://www.npmjs.com/package/suncalc 795 | */ 796 | // 797 | // Use https://www.latlong.net/ to get latidute and longitude based on your adress 798 | // 799 | // sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas 800 | // shortcuts for easier to read formulas 801 | const PI = Math.PI, sin = Math.sin, cos = Math.cos, tan = Math.tan, asin = Math.asin, atan = Math.atan2, acos = Math.acos, rad = PI / 180; 802 | // date/time constants and conversions 803 | const dayMs = 1000 * 60 * 60 * 24, J1970 = 2440588, J2000 = 2451545; 804 | function toJulian(date) { return date.valueOf() / dayMs - 0.5 + J1970; } 805 | function fromJulian(j) { return new Date((j + 0.5 - J1970) * dayMs); } 806 | function toDays(date) { return toJulian(date) - J2000; } 807 | // general calculations for position 808 | const e = rad * 23.4397; // obliquity of the Earth 809 | function rightAscension(l, b) { return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); } 810 | function declination(l, b) { return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); } 811 | function azimuth(H, phi, dec) { return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)); } 812 | function altitude(H, phi, dec) { return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); } 813 | function siderealTime(d, lw) { return rad * (280.16 + 360.9856235 * d) - lw; } 814 | function astroRefraction(h) { 815 | if (h < 0) // the following formula works for positive altitudes only. 816 | h = 0; // if h = -0.08901179 a div/0 would occur. 817 | // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 818 | // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad: 819 | return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179)); 820 | } 821 | // general sun calculations 822 | function solarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028 * d); } 823 | function eclipticLongitude(M) { 824 | const C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center 825 | P = rad * 102.9372; // perihelion of the Earth 826 | return M + C + P + PI; 827 | } 828 | function sunCoords(d) { 829 | const M = solarMeanAnomaly(d), L = eclipticLongitude(M); 830 | return { 831 | dec: declination(L, 0), 832 | ra: rightAscension(L, 0) 833 | }; 834 | } 835 | // calculations for sun times 836 | const J0 = 0.0009; 837 | function julianCycle(d, lw) { return Math.round(d - J0 - lw / (2 * PI)); } 838 | function approxTransit(Ht, lw, n) { return J0 + (Ht + lw) / (2 * PI) + n; } 839 | function solarTransitJ(ds, M, L) { return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L); } 840 | function hourAngle(h, phi, d) { return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))); } 841 | function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; } 842 | // returns set time for the given sun altitude 843 | function getSetJ(h, lw, phi, dec, n, M, L) { 844 | const w = hourAngle(h, phi, dec), a = approxTransit(w, lw, n); 845 | return solarTransitJ(a, M, L); 846 | } 847 | // moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas 848 | function moonCoords(d) { 849 | const L = rad * (218.316 + 13.176396 * d), // ecliptic longitude 850 | M = rad * (134.963 + 13.064993 * d), // mean anomaly 851 | F = rad * (93.272 + 13.229350 * d), // mean distance 852 | l = L + rad * 6.289 * sin(M), // longitude 853 | b = rad * 5.128 * sin(F), // latitude 854 | dt = 385001 - 20905 * cos(M); // distance to the moon in km 855 | return { 856 | ra: rightAscension(l, b), 857 | dec: declination(l, b), 858 | dist: dt 859 | }; 860 | } 861 | function hoursLater(date, h) { 862 | return new Date(date.valueOf() + h * dayMs / 24); 863 | } 864 | class SunCalc { 865 | constructor() { 866 | // sun times configuration (angle, morning name, evening name) 867 | this.times = [ 868 | [-0.833, 'sunrise', 'sunset'], 869 | [-0.3, 'sunriseEnd', 'sunsetStart'], 870 | [-6, 'dawn', 'dusk'], 871 | [-12, 'nauticalDawn', 'nauticalDusk'], 872 | [-18, 'nightEnd', 'night'], 873 | [6, 'goldenHourEnd', 'goldenHour'] 874 | ]; 875 | } 876 | // calculates sun position for a given date and latitude/longitude 877 | // @ts-ignore: Unused method 878 | getPosition(date, lat, lng) { 879 | const lw = rad * -lng, phi = rad * lat, d = toDays(date), c = sunCoords(d), H = siderealTime(d, lw) - c.ra; 880 | return { 881 | azimuth: azimuth(H, phi, c.dec), 882 | altitude: altitude(H, phi, c.dec) 883 | }; 884 | } 885 | // adds a custom time to the times config 886 | // @ts-ignore: Unused method 887 | addTime(angle, riseName, setName) { 888 | this.times.push([angle, riseName, setName]); 889 | } 890 | // calculates sun times for a given date, latitude/longitude, and, optionally, 891 | // the observer height (in meters) relative to the horizon 892 | getTimes(date, lat, lng, height) { 893 | height = height || 0; 894 | const lw = rad * -lng, phi = rad * lat, dh = observerAngle(height), d = toDays(date), n = julianCycle(d, lw), ds = approxTransit(0, lw, n), M = solarMeanAnomaly(ds), L = eclipticLongitude(M), dec = declination(L, 0), Jnoon = solarTransitJ(ds, M, L); 895 | let i, len, time, h0, Jset, Jrise; 896 | const result = { 897 | solarNoon: fromJulian(Jnoon), 898 | nadir: fromJulian(Jnoon - 0.5) 899 | }; 900 | for (i = 0, len = this.times.length; i < len; i += 1) { 901 | time = this.times[i]; 902 | h0 = (time[0] + dh) * rad; 903 | Jset = getSetJ(h0, lw, phi, dec, n, M, L); 904 | Jrise = Jnoon - (Jset - Jnoon); 905 | result[time[1]] = fromJulian(Jrise); 906 | result[time[2]] = fromJulian(Jset); 907 | } 908 | return result; 909 | } 910 | getMoonPosition(date, lat, lng) { 911 | const lw = rad * -lng, phi = rad * lat, d = toDays(date), c = moonCoords(d), H = siderealTime(d, lw) - c.ra; 912 | let h = altitude(H, phi, c.dec); 913 | // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 914 | const pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H)); 915 | h = h + astroRefraction(h); // altitude correction for refraction 916 | return { 917 | azimuth: azimuth(H, phi, c.dec), 918 | altitude: h, 919 | distance: c.dist, 920 | parallacticAngle: pa 921 | }; 922 | } 923 | // calculations for illumination parameters of the moon, 924 | // based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and 925 | // Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 926 | // @ts-ignore: Unused method 927 | getMoonIllumination(date) { 928 | const d = toDays(date || new Date()), s = sunCoords(d), m = moonCoords(d), sdist = 149598000, // distance from Earth to Sun in km 929 | phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)), inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)), angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) - 930 | cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra)); 931 | return { 932 | fraction: (1 + cos(inc)) / 2, 933 | phase: 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI, 934 | angle: angle 935 | }; 936 | } 937 | // calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article 938 | // @ts-ignore: Unused method 939 | getMoonTimes(date, lat, lng, inUTC) { 940 | const t = new Date(date); 941 | if (inUTC) 942 | t.setUTCHours(0, 0, 0, 0); 943 | else 944 | t.setHours(0, 0, 0, 0); 945 | const hc = 0.133 * rad; 946 | let h0 = this.getMoonPosition(t, lat, lng).altitude - hc; 947 | let h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx; 948 | // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set) 949 | for (let i = 1; i <= 24; i += 2) { 950 | h1 = this.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc; 951 | h2 = this.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc; 952 | a = (h0 + h2) / 2 - h1; 953 | b = (h2 - h0) / 2; 954 | xe = -b / (2 * a); 955 | ye = (a * xe + b) * xe + h1; 956 | d = b * b - 4 * a * h1; 957 | roots = 0; 958 | if (d >= 0) { 959 | dx = Math.sqrt(d) / (Math.abs(a) * 2); 960 | x1 = xe - dx; 961 | x2 = xe + dx; 962 | if (Math.abs(x1) <= 1) 963 | roots++; 964 | if (Math.abs(x2) <= 1) 965 | roots++; 966 | if (x1 < -1) 967 | x1 = x2; 968 | } 969 | if (roots === 1) { 970 | if (h0 < 0) 971 | rise = i + x1; 972 | else 973 | set = i + x1; 974 | } 975 | else if (roots === 2) { 976 | rise = i + (ye < 0 ? x2 : x1); 977 | set = i + (ye < 0 ? x1 : x2); 978 | } 979 | if (rise && set) 980 | break; 981 | h0 = h2; 982 | } 983 | const result = {}; 984 | if (rise) 985 | result[rise] = hoursLater(t, rise); 986 | if (set) 987 | result[set] = hoursLater(t, set); 988 | if (!rise && !set) 989 | result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true; 990 | return result; 991 | } 992 | } 993 | module.exports = AutomationsExtension; 994 | -------------------------------------------------------------------------------- /loadExtension.ps1: -------------------------------------------------------------------------------- 1 | # Define the path to your JavaScript file and the temporary JSON file 2 | $jsFilePath = ".\dist\automations.js" 3 | $jsonFilePath = ".\dist\__temp__automations.js" 4 | 5 | # Read the content of the JS file, escape double quotes and replace newlines with \n 6 | $jsContent = (Get-Content -Path $jsFilePath -Raw) -replace '\\', '\\' -replace '"', '\"' -replace "`n", '\n' 7 | 8 | # Define the complete JSON payload with the JS content 9 | $jsonPayload = '{"name": "automations.js", "code": "' + $jsContent + '", "transaction": "Luligu"}' 10 | 11 | # Write the complete JSON payload to the temporary file 12 | $jsonPayload | Set-Content -Path $jsonFilePath 13 | 14 | # Use the temporary file in the mosquitto_pub command 15 | & 'C:\Program Files\mosquitto\mosquitto_pub' -h localhost -t 'zigbee2mqtt/bridge/request/extension/save' -f $jsonFilePath 16 | & 'C:\Program Files\mosquitto\mosquitto_pub' -h raspberrypi.local -t 'zigbee2mqtt/bridge/request/extension/save' -f $jsonFilePath 17 | 18 | # Optionally, clean up the temporary file after publishing 19 | Remove-Item -Path $jsonFilePath -------------------------------------------------------------------------------- /loadExtension.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Define the path to your JavaScript file and the temporary JSON file 4 | jsFilePath="./dist/automations.js" 5 | jsonFilePath="./dist/__temp__automations.json" 6 | 7 | # Read the content of the JS file, escape double quotes and newlines 8 | jsContent=$(<"$jsFilePath") 9 | escapedJsContent=$(echo "$jsContent" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g') 10 | 11 | # Define the complete JSON payload with the JS content 12 | jsonPayload="{\"name\": \"automations.js\", \"code\": \"$escapedJsContent\", \"transaction\": \"Luligu\"}" 13 | 14 | # Write the complete JSON payload to the temporary file 15 | echo "$jsonPayload" > "$jsonFilePath" 16 | 17 | # Use the temporary file in the mosquitto_pub command 18 | mosquitto_pub -h localhost -t 'zigbee2mqtt/bridge/request/extension/save' -f "$jsonFilePath" 19 | 20 | # Optionally, clean up the temporary file after publishing 21 | rm "$jsonFilePath" 22 | -------------------------------------------------------------------------------- /matterbridge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 45 | 48 | 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigbee2mqtt-automations", 3 | "version": "2.0.5", 4 | "description": "Automations extension for zigbee2mqtt", 5 | "author": "Luligu", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/Luligu/zigbee2mqtt-automations" 10 | }, 11 | "keywords": [ 12 | "bridge", 13 | "zigbee", 14 | "mqtt", 15 | "mqtt-accessories", 16 | "zigbee2mqtt", 17 | "zigbee-herdsman", 18 | "zigbee-herdsman-converters", 19 | "frontend", 20 | "automations", 21 | "extensions", 22 | "automation", 23 | "extension" 24 | ], 25 | "bugs": { 26 | "url": "https://github.com/Luligu/zigbee2mqtt-automations/issues" 27 | }, 28 | "homepage": "https://github.com/Luligu/zigbee2mqtt-automations#readme", 29 | "devDependencies": { 30 | "@types/node": "^18.17.1", 31 | "@typescript-eslint/eslint-plugin": "^6.8.0", 32 | "@typescript-eslint/parser": "^6.8.0", 33 | "eslint": "^8.51.0", 34 | "typescript": "^5.0.4", 35 | "zigbee2mqtt": "^1.33.1" 36 | }, 37 | "scripts": { 38 | "start": "tsc --watch", 39 | "build": "tsc" 40 | } 41 | } -------------------------------------------------------------------------------- /src/automations.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains the class AutomationsExtension and its definitions. 3 | * 4 | * @file automations.ts 5 | * @author Luligu (https://github.com/Luligu) 6 | * @copyright 2023, 2024, 2025 Luligu 7 | * @date 2023-10-15 8 | * 9 | * See LICENSE in the root. 10 | * 11 | */ 12 | 13 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 14 | /* eslint-disable @typescript-eslint/no-var-requires */ 15 | 16 | import { Buffer } from 'buffer'; 17 | 18 | import type Zigbee from 'zigbee2mqtt/dist/zigbee'; 19 | import type MQTT from 'zigbee2mqtt/dist/mqtt'; 20 | import type State from 'zigbee2mqtt/dist/state'; 21 | import type Device from 'zigbee2mqtt/dist/model/device'; 22 | import type Group from 'zigbee2mqtt/dist/model/device'; 23 | import type EventBus from 'zigbee2mqtt/dist/eventBus'; 24 | import type Extension from 'zigbee2mqtt/dist/extension/extension'; 25 | import type Settings from 'zigbee2mqtt/dist/util/settings'; 26 | import type Logger from 'zigbee2mqtt/dist/util/logger'; 27 | 28 | // These packages are defined inside zigbee2mqtt and so they are not available here to import! 29 | // The external extensions are now loaded from a temp directory, we use require to load them from where we know they are 30 | const path = require("node:path"); 31 | 32 | const utilPath = path.join(require.main?.path, "dist", "util") 33 | const joinPath = require(path.join(utilPath, "data")).default.joinPath; 34 | const readIfExists = require(path.join(utilPath, "yaml")).default.readIfExists; 35 | 36 | type StateChangeReason = "publishDebounce" | "groupOptimistic" | "lastSeenChanged" | "publishCached" | "publishThrottle"; 37 | 38 | function toArray(item: T | T[]): T[] { 39 | return Array.isArray(item) ? item : [item]; 40 | } 41 | 42 | enum ConfigSunCalc { 43 | SOLAR_NOON = 'solarNoon', 44 | NADIR = 'nadir', 45 | SUNRISE = 'sunrise', 46 | SUNSET = 'sunset', 47 | SUNRISE_END = 'sunriseEnd', 48 | SUNSET_START = 'sunsetStart', 49 | DAWN = 'dawn', 50 | DUSK = 'dusk', 51 | NAUTICAL_DAWN = 'nauticalDawn', 52 | NAUTICAL_DUSK = 'nauticalDusk', 53 | NIGHT_END = 'nightEnd', 54 | NIGHT = 'night', 55 | GOLDEN_HOUR_END = 'goldenHourEnd', 56 | GOLDEN_HOUR = 'goldenHour', 57 | } 58 | 59 | enum ConfigState { 60 | ON = 'ON', 61 | OFF = 'OFF', 62 | TOGGLE = 'TOGGLE', 63 | } 64 | 65 | enum ConfigPayload { 66 | TOGGLE = 'toggle', 67 | TURN_ON = 'turn_on', 68 | TURN_OFF = 'turn_off', 69 | } 70 | 71 | enum MessagePayload { 72 | EXECUTE = 'execute' 73 | } 74 | 75 | type ConfigStateType = string; 76 | type ConfigPayloadType = string | number | boolean; 77 | type ConfigActionType = string; 78 | type ConfigAttributeType = string; 79 | type ConfigAttributeValueType = string | number | boolean; 80 | 81 | type StateChangeType = string | number | boolean; 82 | type StateChangeUpdate = Record; 83 | type StateChangeFrom = Record; 84 | type StateChangeTo = Record; 85 | 86 | type TriggerForType = number; 87 | type TurnOffAfterType = number; 88 | type ExecuteOnceType = boolean; 89 | type ActiveType = boolean; 90 | type TimeStringType = string; // e.g. "15:05:00" 91 | type LoggerType = string; 92 | 93 | interface ConfigTrigger { 94 | entity: EntityId | EntityId[]; 95 | time: TimeStringType; 96 | } 97 | 98 | interface ConfigTimeTrigger extends ConfigTrigger { 99 | time: TimeStringType; 100 | latitude?: number; 101 | longitude?: number; 102 | elevation?: number; 103 | } 104 | 105 | interface ConfigEventTrigger extends ConfigTrigger { 106 | entity: EntityId | EntityId[]; 107 | for?: TriggerForType; 108 | action?: ConfigActionType | ConfigActionType[]; 109 | state?: ConfigStateType | ConfigStateType[]; 110 | attribute?: ConfigAttributeType; 111 | equal?: ConfigAttributeValueType; 112 | not_equal?: ConfigAttributeValueType; 113 | above?: number; 114 | below?: number; 115 | } 116 | 117 | interface ConfigActionTrigger extends ConfigEventTrigger { 118 | action: ConfigActionType | ConfigActionType[]; 119 | } 120 | 121 | interface ConfigStateTrigger extends ConfigEventTrigger { 122 | state: ConfigStateType | ConfigStateType[]; 123 | } 124 | 125 | interface ConfigAttributeTrigger extends ConfigEventTrigger { 126 | attribute: ConfigAttributeType; 127 | equal?: ConfigAttributeValueType; 128 | not_equal?: ConfigAttributeValueType; 129 | above?: number; 130 | below?: number; 131 | } 132 | 133 | type ConfigActionPayload = Record; 134 | 135 | interface ConfigAction { 136 | entity: EntityId; 137 | payload: ConfigActionPayload; 138 | payload_off?: ConfigActionPayload; 139 | turn_off_after?: TurnOffAfterType; 140 | logger?: LoggerType; 141 | } 142 | 143 | interface ConfigCondition { 144 | } 145 | 146 | interface ConfigEntityCondition extends ConfigCondition { 147 | entity: EntityId; 148 | state?: ConfigStateType; 149 | attribute?: ConfigAttributeType; 150 | equal?: ConfigAttributeValueType; 151 | not_equal?: ConfigAttributeValueType; 152 | above?: number; 153 | below?: number; 154 | } 155 | 156 | interface ConfigTimeCondition extends ConfigCondition { 157 | after?: TimeStringType; 158 | before?: TimeStringType; 159 | between?: TimeStringType; 160 | weekday?: string[]; 161 | } 162 | 163 | // Yaml defined automations 164 | type ConfigAutomations = { 165 | [key: string]: { 166 | execute_once?: ExecuteOnceType; 167 | active?: ActiveType, 168 | trigger: ConfigTrigger | ConfigTrigger[], 169 | action: ConfigAction | ConfigAction[], 170 | condition?: ConfigCondition | ConfigCondition[], 171 | } 172 | }; 173 | 174 | // Internal event based automations 175 | type EventAutomation = { 176 | name: string, 177 | execute_once?: ExecuteOnceType; 178 | trigger: ConfigEventTrigger, 179 | condition: ConfigCondition[], 180 | action: ConfigAction[], 181 | }; 182 | 183 | type EntityId = string; 184 | 185 | type EventAutomations = { 186 | [key: EntityId]: EventAutomation[], 187 | }; 188 | 189 | // Internal time based automations 190 | type TimeAutomation = { 191 | name: string, 192 | execute_once?: ExecuteOnceType; 193 | trigger: ConfigTimeTrigger, 194 | condition: ConfigCondition[], 195 | action: ConfigAction[], 196 | }; 197 | 198 | type TimeId = string; 199 | 200 | type TimeAutomations = { 201 | [key: TimeId]: TimeAutomation[], 202 | }; 203 | 204 | type MQTTMessage = { 205 | topic: string, 206 | message: string 207 | } 208 | 209 | class InternalLogger { 210 | constructor() { } 211 | 212 | debug(message: string, ...args: unknown[]): void { 213 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;247m${message}\x1b[0m`, ...args); 214 | } 215 | 216 | warning(message: string, ...args: unknown[]): void { 217 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;220m${message}\x1b[0m`, ...args); 218 | } 219 | 220 | info(message: string, ...args: unknown[]): void { 221 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;255m${message}\x1b[0m`, ...args); 222 | } 223 | 224 | error(message: string, ...args: unknown[]): void { 225 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;9m${message}\x1b[0m`, ...args); 226 | } 227 | } 228 | 229 | class AutomationsExtension { 230 | private readonly mqttBaseTopic: string; 231 | private readonly automationsTopic: string; 232 | private readonly topicRegex: RegExp; 233 | private readonly eventAutomations: EventAutomations = {}; 234 | private timeAutomations: TimeAutomations = {}; 235 | private readonly triggerForTimeouts: Record; 236 | private readonly turnOffAfterTimeouts: Record; 237 | private midnightTimeout: NodeJS.Timeout; 238 | private readonly log: InternalLogger; 239 | 240 | constructor( 241 | protected zigbee: Zigbee, 242 | protected mqtt: MQTT, 243 | protected state: State, 244 | protected publishEntityState: (entity: Device | Group, payload: Record, stateChangeReason?: StateChangeReason) => Promise, 245 | protected eventBus: EventBus, 246 | protected enableDisableExtension: (enable: boolean, name: string) => Promise, 247 | protected restartCallback: () => Promise, 248 | protected addExtension: (extension: Extension) => Promise, 249 | protected settings: typeof Settings, 250 | protected logger: typeof Logger, 251 | ) { 252 | this.log = new InternalLogger(); 253 | this.mqttBaseTopic = settings.get().mqtt.base_topic; 254 | this.triggerForTimeouts = {}; 255 | this.turnOffAfterTimeouts = {}; 256 | this.automationsTopic = 'zigbee2mqtt-automations'; 257 | // eslint-disable-next-line no-useless-escape 258 | this.topicRegex = new RegExp(`^${this.automationsTopic}\/(.*)`); 259 | 260 | this.logger.info(`[Automations] Loading automation.js`); 261 | 262 | if (!this.parseConfig()) 263 | return; 264 | 265 | /* 266 | this.log.info(`Event automation:`); 267 | Object.keys(this.eventAutomations).forEach(key => { 268 | const eventAutomationArray = this.eventAutomations[key]; 269 | eventAutomationArray.forEach(eventAutomation => { 270 | this.log.info(`- key: #${key}# automation: ${this.stringify(eventAutomation, true)}`); 271 | }); 272 | }); 273 | */ 274 | //this.log.info(`Time automation:`); 275 | Object.keys(this.timeAutomations).forEach(key => { 276 | const timeAutomationArray = this.timeAutomations[key]; 277 | timeAutomationArray.forEach(timeAutomation => { 278 | //this.log.info(`- key: #${key}# automation: ${this.stringify(timeAutomation, true)}`); 279 | this.startTimeTriggers(key, timeAutomation); 280 | }); 281 | }); 282 | 283 | this.startMidnightTimeout(); 284 | 285 | this.logger.info(`[Automations] Automation.js loaded`); 286 | } 287 | 288 | private parseConfig(): boolean { 289 | let configAutomations: ConfigAutomations = {}; 290 | try { 291 | // configAutomations = (yaml.readIfExists(data.joinPath('automations.yaml')) || {}) as ConfigAutomations; 292 | configAutomations = (readIfExists(joinPath('automations.yaml')) || {}) as ConfigAutomations; 293 | } 294 | catch (error) { 295 | this.logger.error(`[Automations] Error loading file automations.yaml: see stderr for explanation`); 296 | console.log(error); 297 | return false; 298 | } 299 | 300 | Object.entries(configAutomations).forEach(([key, configAutomation]) => { 301 | const actions = toArray(configAutomation.action); 302 | const conditions = configAutomation.condition ? toArray(configAutomation.condition) : []; 303 | const triggers = toArray(configAutomation.trigger); 304 | 305 | // Check automation 306 | if (configAutomation.active === false) { 307 | this.logger.info(`[Automations] Automation [${key}] not registered since active is false`); 308 | return; 309 | } 310 | if (!configAutomation.trigger) { 311 | this.logger.error(`[Automations] Config validation error for [${key}]: no triggers defined`); 312 | return; 313 | } 314 | if (!configAutomation.action) { 315 | this.logger.error(`[Automations] Config validation error for [${key}]: no actions defined`); 316 | return; 317 | } 318 | // Check triggers 319 | for (const trigger of triggers) { 320 | if (!trigger.time && !trigger.entity) { 321 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity not defined`); 322 | return; 323 | } 324 | if (!trigger.time && !this.zigbee.resolveEntity(trigger.entity)) { 325 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${trigger.entity}# not found`); 326 | return; 327 | } 328 | } 329 | // Check actions 330 | for (const action of actions) { 331 | if (!action.entity) { 332 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity not defined`); 333 | return; 334 | } 335 | if (!this.zigbee.resolveEntity(action.entity)) { 336 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity #${action.entity}# not found`); 337 | return; 338 | } 339 | if (!action.payload) { 340 | this.logger.error(`[Automations] Config validation error for [${key}]: action payload not defined`); 341 | return; 342 | } 343 | } 344 | // Check conditions 345 | for (const condition of conditions) { 346 | if (!(condition as ConfigEntityCondition).entity && !(condition as ConfigTimeCondition).after && !(condition as ConfigTimeCondition).before && !(condition as ConfigTimeCondition).between && !(condition as ConfigTimeCondition).weekday) { 347 | this.logger.error(`[Automations] Config validation error for [${key}]: condition unknown`); 348 | return; 349 | } 350 | if ((condition as ConfigEntityCondition).entity && !this.zigbee.resolveEntity((condition as ConfigEntityCondition).entity)) { 351 | this.logger.error(`[Automations] Config validation error for [${key}]: condition entity #${(condition as ConfigEntityCondition).entity}# not found`); 352 | return; 353 | } 354 | } 355 | 356 | for (const trigger of triggers) { 357 | if (trigger.time !== undefined) { 358 | const timeTrigger = trigger as ConfigTimeTrigger; 359 | this.logger.info(`[Automations] Registering time automation [${key}] trigger: ${timeTrigger.time}`); 360 | const suncalcs = Object.values(ConfigSunCalc); 361 | if (suncalcs.includes(timeTrigger.time as ConfigSunCalc)) { 362 | if (!timeTrigger.latitude || !timeTrigger.longitude) { 363 | this.logger.error(`[Automations] Config validation error for [${key}]: latitude and longitude are mandatory for ${trigger.time}`); 364 | return; 365 | } 366 | const suncalc = new SunCalc(); 367 | const times = suncalc.getTimes(new Date(), timeTrigger.latitude, timeTrigger.longitude, timeTrigger.elevation ? timeTrigger.elevation : 0) as object; 368 | this.logger.debug(`[Automations] Sunrise at ${times[ConfigSunCalc.SUNRISE].toLocaleTimeString()} sunset at ${times[ConfigSunCalc.SUNSET].toLocaleTimeString()} for latitude:${timeTrigger.latitude} longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0}`); 369 | this.log.debug(`[Automations] For latitude:${timeTrigger.latitude} longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0} suncalc are:\n`, times); 370 | const options = { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }; 371 | const time = times[trigger.time].toLocaleTimeString('en-GB', options); 372 | this.log.debug(`[Automations] Registering time automation [${key}] trigger: ${time}`); 373 | if (!this.timeAutomations[time]) 374 | this.timeAutomations[time] = []; 375 | this.timeAutomations[time].push({ name: key, execute_once: configAutomation.execute_once, trigger: timeTrigger, action: actions, condition: conditions }); 376 | } else if (this.matchTimeString(timeTrigger.time)) { 377 | if (!this.timeAutomations[timeTrigger.time]) 378 | this.timeAutomations[timeTrigger.time] = []; 379 | this.timeAutomations[timeTrigger.time].push({ name: key, execute_once: configAutomation.execute_once, trigger: timeTrigger, action: actions, condition: conditions }); 380 | } else { 381 | this.logger.error(`[Automations] Config validation error for [${key}]: time syntax error for ${trigger.time}`); 382 | return; 383 | } 384 | } 385 | if (trigger.entity !== undefined) { 386 | const eventTrigger = trigger as ConfigEventTrigger; 387 | if (!this.zigbee.resolveEntity(eventTrigger.entity)) { 388 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${eventTrigger.entity}# not found`); 389 | return; 390 | } 391 | this.logger.info(`[Automations] Registering event automation [${key}] trigger: entity #${eventTrigger.entity}#`); 392 | const entities = toArray(eventTrigger.entity); 393 | for (const entity of entities) { 394 | if (!this.eventAutomations[entity]) { 395 | this.eventAutomations[entity] = []; 396 | } 397 | this.eventAutomations[entity].push({ name: key, execute_once: configAutomation.execute_once, trigger: eventTrigger, action: actions, condition: conditions }); 398 | } 399 | } 400 | } // for (const trigger of triggers) 401 | }); 402 | return true; 403 | } 404 | 405 | /** 406 | * Check a time string and return a Date or undefined if error 407 | */ 408 | private matchTimeString(timeString: TimeStringType): Date | undefined { 409 | if (timeString.length !== 8) 410 | return undefined; 411 | const match = timeString.match(/(\d{2}):(\d{2}):(\d{2})/); 412 | if (match && parseInt(match[1], 10) <= 23 && parseInt(match[2], 10) <= 59 && parseInt(match[3], 10) <= 59) { 413 | const time = new Date(); 414 | time.setHours(parseInt(match[1], 10)); 415 | time.setMinutes(parseInt(match[2], 10)); 416 | time.setSeconds(parseInt(match[3], 10)); 417 | return time; 418 | } 419 | return undefined; 420 | } 421 | 422 | /** 423 | * Start a timeout in the first second of tomorrow date. 424 | * It also reload the suncalc times for the next day. 425 | * The timeout callback then will start the time triggers for tomorrow and start again a timeout for the next day. 426 | */ 427 | private startMidnightTimeout(): void { 428 | const now = new Date(); 429 | const timeEvent = new Date(); 430 | timeEvent.setHours(23); 431 | timeEvent.setMinutes(59); 432 | timeEvent.setSeconds(59); 433 | this.logger.debug(`[Automations] Set timeout to reload for time automations`); 434 | this.midnightTimeout = setTimeout(() => { 435 | this.logger.info(`[Automations] Run timeout to reload time automations`); 436 | 437 | const newTimeAutomations = {}; 438 | const suncalcs = Object.values(ConfigSunCalc); 439 | Object.keys(this.timeAutomations).forEach(key => { 440 | const timeAutomationArray = this.timeAutomations[key]; 441 | timeAutomationArray.forEach(timeAutomation => { 442 | if(suncalcs.includes(timeAutomation.trigger.time as ConfigSunCalc)) { 443 | if (!timeAutomation.trigger.latitude || !timeAutomation.trigger.longitude) return; 444 | const suncalc = new SunCalc(); 445 | const times = suncalc.getTimes(new Date(), timeAutomation.trigger.latitude, timeAutomation.trigger.longitude, timeAutomation.trigger.elevation ?? 0); 446 | // this.log.info(`Key:[${key}] For latitude:${timeAutomation.trigger.latitude} longitude:${timeAutomation.trigger.longitude} elevation:${timeAutomation.trigger.elevation ?? 0} suncalcs are:\n`, times); 447 | const options = { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }; 448 | const time = times[timeAutomation.trigger.time].toLocaleTimeString('en-GB', options); 449 | // this.log.info(`Registering suncalc time automation at time [${time}] for:`, timeAutomation); 450 | this.logger.info(`[Automations] Registering suncalc time automation at time [${time}] for: ${this.stringify(timeAutomation)}`); 451 | if (!newTimeAutomations[time]) newTimeAutomations[time] = []; 452 | newTimeAutomations[time].push(timeAutomation); 453 | this.startTimeTriggers(time, timeAutomation); 454 | } else { 455 | // this.log.info(`Registering normal time automation at time [${key}] for:`, timeAutomation); 456 | this.logger.info(`[Automations] Registering normal time automation at time [${key}] for: ${this.stringify(timeAutomation)}`); 457 | if (!newTimeAutomations[key]) newTimeAutomations[key] = []; 458 | newTimeAutomations[key].push(timeAutomation); 459 | this.startTimeTriggers(key, timeAutomation); 460 | } 461 | }); 462 | }); 463 | this.timeAutomations = newTimeAutomations; 464 | 465 | this.startMidnightTimeout(); 466 | }, timeEvent.getTime() - now.getTime() + 2000); 467 | this.midnightTimeout.unref(); 468 | } 469 | 470 | /** 471 | * Take the key of TimeAutomations that is a string like hh:mm:ss, convert it in a Date object of today 472 | * and set the timer if not already passed for today. 473 | * The timeout callback then will run the automations 474 | */ 475 | private startTimeTriggers(key: TimeId, automation: TimeAutomation): void { 476 | const now = new Date(); 477 | 478 | const timeEvent = this.matchTimeString(key); 479 | if (timeEvent !== undefined) { 480 | if (timeEvent.getTime() > now.getTime()) { 481 | this.logger.debug(`[Automations] Set timeout at ${timeEvent.toLocaleString()} for [${automation.name}]`); 482 | const timeout = setTimeout(() => { 483 | delete this.triggerForTimeouts[automation.name]; 484 | this.logger.debug(`[Automations] Timeout for [${automation.name}]`); 485 | this.runActionsWithConditions(automation, automation.condition, automation.action); 486 | }, timeEvent.getTime() - now.getTime()); 487 | timeout.unref(); 488 | this.triggerForTimeouts[automation.name] = timeout; 489 | } 490 | else { 491 | this.logger.debug(`[Automations] Timeout at ${timeEvent.toLocaleString()} is passed for [${automation.name}]`); 492 | } 493 | } else { 494 | this.logger.error(`[Automations] Timeout config error at ${key} for [${automation.name}]`); 495 | } 496 | } 497 | 498 | /** 499 | * null - return 500 | * false - return and stop timer 501 | * true - start the automation 502 | */ 503 | private checkTrigger(automation: EventAutomation, configTrigger: ConfigEventTrigger, update: StateChangeUpdate, from: StateChangeFrom, to: StateChangeTo): boolean | null { 504 | let trigger; 505 | let attribute; 506 | let result; 507 | let actions; 508 | 509 | //this.log.warning(`[Automations] Trigger check [${automation.name}] update: ${this.stringify(update)} from: ${this.stringify(from)} to: ${this.stringify(to)}`); 510 | 511 | if (configTrigger.action !== undefined) { 512 | if (!Object.prototype.hasOwnProperty.call(update, 'action')) { 513 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no 'action' in update for #${configTrigger.entity}#`); 514 | return null; 515 | } 516 | trigger = configTrigger as ConfigActionTrigger; 517 | actions = toArray(trigger.action); 518 | result = actions.includes(update.action as ConfigActionType); 519 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger is ${result} for #${configTrigger.entity}# action(s): ${this.stringify(actions)}`); 520 | return result; 521 | } else if (configTrigger.attribute !== undefined) { 522 | trigger = configTrigger as ConfigAttributeTrigger; 523 | attribute = trigger.attribute; 524 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) { 525 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`); 526 | return null; 527 | } 528 | if (from[attribute] === to[attribute]) { 529 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`); 530 | return null; 531 | } 532 | 533 | if (typeof trigger.equal !== 'undefined' || typeof trigger.state !== 'undefined') { 534 | const value = trigger.state !== undefined ? trigger.state : trigger.equal; 535 | if (to[attribute] !== value) { 536 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${value} for #${configTrigger.entity}#`); 537 | return false; 538 | } 539 | if (from[attribute] === value) { 540 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already = ${value} for #${configTrigger.entity}#`); 541 | return null; 542 | } 543 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger equal/state ${value} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 544 | } 545 | 546 | if (typeof trigger.not_equal !== 'undefined') { 547 | if (to[attribute] === trigger.not_equal) { 548 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' = ${trigger.not_equal} for #${configTrigger.entity}#`); 549 | return false; 550 | } 551 | if (from[attribute] !== trigger.not_equal) { 552 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already != ${trigger.not_equal} for #${configTrigger.entity}#`); 553 | return null; 554 | } 555 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger not equal ${trigger.not_equal} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 556 | } 557 | 558 | if (typeof trigger.above !== 'undefined') { 559 | if (to[attribute] <= trigger.above) { 560 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' <= ${trigger.above} for #${configTrigger.entity}#`); 561 | return false; 562 | } 563 | if (from[attribute] > trigger.above) { 564 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already > ${trigger.above} for #${configTrigger.entity}#`); 565 | return null; 566 | } 567 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger above ${trigger.above} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 568 | } 569 | 570 | if (typeof trigger.below !== 'undefined') { 571 | if (to[attribute] >= trigger.below) { 572 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' >= ${trigger.below} for #${configTrigger.entity}#`); 573 | return false; 574 | } 575 | if (from[attribute] < trigger.below) { 576 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already < ${trigger.below} for #${configTrigger.entity}#`); 577 | return null; 578 | } 579 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger below ${trigger.below} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `); 580 | } 581 | return true; 582 | } else if (configTrigger.state !== undefined) { 583 | trigger = configTrigger as ConfigStateTrigger; 584 | attribute = 'state'; 585 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) { 586 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`); 587 | return null; 588 | } 589 | if (from[attribute] === to[attribute]) { 590 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`); 591 | return null; 592 | } 593 | if (to[attribute] !== trigger.state) { 594 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${trigger.state} for #${configTrigger.entity}#`); 595 | return null; 596 | } 597 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger state ${trigger.state} is true for #${configTrigger.entity}# state is ${to[attribute]}`); 598 | return true; 599 | } 600 | return false; 601 | } 602 | 603 | private checkCondition(automation: EventAutomation, condition: ConfigCondition): boolean { 604 | let timeResult = true; 605 | let eventResult = true; 606 | 607 | if ((condition as ConfigTimeCondition).after || (condition as ConfigTimeCondition).before || (condition as ConfigTimeCondition).between || (condition as ConfigTimeCondition).weekday) { 608 | timeResult = this.checkTimeCondition(automation, condition as ConfigTimeCondition); 609 | } 610 | if ((condition as ConfigEntityCondition).entity) { 611 | eventResult = this.checkEntityCondition(automation, condition as ConfigEntityCondition); 612 | } 613 | return (timeResult && eventResult); 614 | } 615 | 616 | // Return false if condition is false 617 | private checkTimeCondition(automation: EventAutomation, condition: ConfigTimeCondition): boolean { 618 | //this.logger.info(`[Automations] checkTimeCondition [${automation.name}]: ${this.stringify(condition)}`); 619 | const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; 620 | const now = new Date(); 621 | if (condition.weekday && !condition.weekday.includes(days[now.getDay()])) { 622 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for weekday: ${this.stringify(condition.weekday)} since today is ${days[now.getDay()]}`); 623 | return false; 624 | } 625 | if (condition.before) { 626 | const time = this.matchTimeString(condition.before); 627 | if (time !== undefined) { 628 | if (now.getTime() > time.getTime()) { 629 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for before: ${condition.before} since now is ${now.toLocaleTimeString()}`); 630 | return false; 631 | } 632 | } else { 633 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: before #${condition.before}# ignoring condition`); 634 | } 635 | } 636 | if (condition.after) { 637 | const time = this.matchTimeString(condition.after); 638 | if (time !== undefined) { 639 | if (now.getTime() < time.getTime()) { 640 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for after: ${condition.after} since now is ${now.toLocaleTimeString()}`); 641 | return false; 642 | } 643 | } else { 644 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: after #${condition.after}# ignoring condition`); 645 | } 646 | } 647 | if (condition.between) { 648 | const [startTimeStr, endTimeStr] = condition.between.split('-'); 649 | const startTime = this.matchTimeString(startTimeStr); 650 | const endTime = this.matchTimeString(endTimeStr); 651 | if (startTime !== undefined && endTime !== undefined) { 652 | // Internal time span: between: 08:00:00-20:00:00 653 | if (startTime.getTime() < endTime.getTime()) { 654 | if (now.getTime() < startTime.getTime() || now.getTime() > endTime.getTime()) { 655 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`); 656 | return false; 657 | } 658 | } 659 | // External time span: between: 20:00:00-06:00:00 660 | else if (startTime.getTime() > endTime.getTime()) { 661 | if (now.getTime() < startTime.getTime() && now.getTime() > endTime.getTime()) { 662 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`); 663 | return false; 664 | } 665 | } 666 | } else { 667 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: between #${condition.between}# ignoring condition`); 668 | } 669 | } 670 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is true for ${this.stringify(condition)}`); 671 | return true; 672 | } 673 | 674 | // Return false if condition is false 675 | private checkEntityCondition(automation: EventAutomation, condition: ConfigEntityCondition): boolean { 676 | if (!condition.entity) { 677 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: condition entity not specified`); 678 | return false; 679 | } 680 | const entity = this.zigbee.resolveEntity(condition.entity); 681 | if (!entity) { 682 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: entity #${condition.entity}# not found`); 683 | return false; 684 | } 685 | const attribute = condition.attribute || 'state'; 686 | const value = this.state.get(entity)[attribute]; 687 | if (condition.state !== undefined && value !== condition.state) { 688 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not '${condition.state}'`); 689 | return false; 690 | } 691 | if (condition.attribute !== undefined && condition.equal !== undefined && value !== condition.equal) { 692 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not equal '${condition.equal}'`); 693 | return false; 694 | } 695 | if (condition.attribute !== undefined && condition.below !== undefined && value >= condition.below) { 696 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not below '${condition.below}'`); 697 | return false; 698 | } 699 | if (condition.attribute !== undefined && condition.above !== undefined && value <= condition.above) { 700 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not above '${condition.above}'`); 701 | return false; 702 | } 703 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is true for entity #${condition.entity}# attribute '${attribute}' is '${value}'`); 704 | return true; 705 | } 706 | 707 | private runActions(automation: EventAutomation, actions: ConfigAction[]): void { 708 | for (const action of actions) { 709 | const entity = this.zigbee.resolveEntity(action.entity); 710 | if (!entity) { 711 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`); 712 | continue; 713 | } 714 | let data: ConfigActionPayload; 715 | //this.log.warn('Payload:', typeof action.payload, action.payload) 716 | if (typeof action.payload === 'string') { 717 | if (action.payload === ConfigPayload.TURN_ON) { 718 | data = { state: ConfigState.ON }; 719 | } else if (action.payload === ConfigPayload.TURN_OFF) { 720 | data = { state: ConfigState.OFF }; 721 | } else if (action.payload === ConfigPayload.TOGGLE) { 722 | data = { state: ConfigState.TOGGLE }; 723 | } else { 724 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`); 725 | return; 726 | } 727 | } else if (typeof action.payload === 'object') { 728 | data = action.payload; 729 | } else { 730 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`); 731 | return; 732 | } 733 | if (action.logger === 'info') 734 | this.logger.info(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 735 | else if (action.logger === 'warning') 736 | this.logger.warning(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 737 | else if (action.logger === 'error') 738 | this.logger.error(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 739 | else 740 | this.logger.debug(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`); 741 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, Buffer.from(this.payloadStringify(data))); 742 | if (action.turn_off_after) { 743 | this.startActionTurnOffTimeout(automation, action); 744 | } 745 | } // End for (const action of actions) 746 | if (automation.execute_once === true) { 747 | this.removeAutomation(automation.name); 748 | } 749 | } 750 | 751 | // Remove automation that has execute_once: true 752 | private removeAutomation(name: string): void { 753 | this.logger.debug(`[Automations] Uregistering automation [${name}]`); 754 | //this.log.warning(`Uregistering automation [${name}]`); 755 | Object.keys(this.eventAutomations).forEach((entity) => { 756 | //this.log.warning(`Entity: #${entity}#`); 757 | Object.values(this.eventAutomations[entity]).forEach((eventAutomation, index) => { 758 | if (eventAutomation.name === name) { 759 | //this.log.warning(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`); 760 | this.eventAutomations[entity].splice(index, 1); 761 | } 762 | else { 763 | //this.log.info(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`); 764 | } 765 | }); 766 | }); 767 | Object.keys(this.timeAutomations).forEach((now) => { 768 | //this.log.warning(`Time: #${now}#`); 769 | Object.values(this.timeAutomations[now]).forEach((timeAutomation, index) => { 770 | if (timeAutomation.name === name) { 771 | //this.log.warning(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`); 772 | this.timeAutomations[now].splice(index, 1); 773 | } 774 | else { 775 | //this.log.info(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`); 776 | } 777 | }); 778 | }); 779 | } 780 | 781 | // Stop the turn_off_after timeout 782 | private stopActionTurnOffTimeout(automation: EventAutomation, action: ConfigAction): void { 783 | const timeout = this.turnOffAfterTimeouts[automation.name + action.entity]; 784 | if (timeout) { 785 | this.logger.debug(`[Automations] Stop turn_off_after timeout for automation [${automation.name}]`); 786 | clearTimeout(timeout); 787 | delete this.turnOffAfterTimeouts[automation.name + action.entity]; 788 | } 789 | } 790 | 791 | // Start the turn_off_after timeout 792 | private startActionTurnOffTimeout(automation: EventAutomation, action: ConfigAction): void { 793 | this.stopActionTurnOffTimeout(automation, action); 794 | this.logger.debug(`[Automations] Start ${action.turn_off_after} seconds turn_off_after timeout for automation [${automation.name}]`); 795 | const timeout = setTimeout(() => { 796 | delete this.turnOffAfterTimeouts[automation.name + action.entity]; 797 | //this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}]`); 798 | const entity = this.zigbee.resolveEntity(action.entity); 799 | if (!entity) { 800 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`); 801 | this.stopActionTurnOffTimeout(automation, action); 802 | return; 803 | } 804 | const data = action.payload_off ?? { state: ConfigState.OFF }; 805 | if (action.logger === 'info') 806 | this.logger.info(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 807 | else if (action.logger === 'warning') 808 | this.logger.warning(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 809 | else if (action.logger === 'error') 810 | this.logger.error(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 811 | else 812 | this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `); 813 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, Buffer.from(this.payloadStringify(data))); 814 | }, action.turn_off_after! * 1000); 815 | timeout.unref(); 816 | this.turnOffAfterTimeouts[automation.name + action.entity] = timeout; 817 | } 818 | 819 | private runActionsWithConditions(automation: EventAutomation, conditions: ConfigCondition[], actions: ConfigAction[]): void { 820 | for (const condition of conditions) { 821 | //this.log.warning(`runActionsWithConditions: conditon: ${this.stringify(condition)}`); 822 | if (!this.checkCondition(automation, condition)) { 823 | return; 824 | } 825 | } 826 | this.runActions(automation, actions); 827 | } 828 | 829 | // Stop the trigger_for timeout 830 | private stopTriggerForTimeout(automation: EventAutomation): void { 831 | const timeout = this.triggerForTimeouts[automation.name]; 832 | if (timeout) { 833 | //this.log.debug(`Stop timeout for automation [${automation.name}] trigger: ${this.stringify(automation.trigger)}`); 834 | this.logger.debug(`[Automations] Stop trigger-for timeout for automation [${automation.name}]`); 835 | clearTimeout(timeout); 836 | delete this.triggerForTimeouts[automation.name]; 837 | } 838 | } 839 | 840 | // Start the trigger_for timeout 841 | private startTriggerForTimeout(automation: EventAutomation): void { 842 | if (automation.trigger.for === undefined || automation.trigger.for === 0) { 843 | this.logger.error(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout error for automation [${automation.name}]`); 844 | return; 845 | } 846 | this.logger.debug(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout for automation [${automation.name}]`); 847 | const timeout = setTimeout(() => { 848 | delete this.triggerForTimeouts[automation.name]; 849 | this.logger.debug(`[Automations] Trigger-for timeout for automation [${automation.name}]`); 850 | this.runActionsWithConditions(automation, automation.condition, automation.action); 851 | }, automation.trigger.for * 1000); 852 | timeout.unref(); 853 | this.triggerForTimeouts[automation.name] = timeout; 854 | } 855 | 856 | private runAutomationIfMatches(automation: EventAutomation, update: StateChangeUpdate, from: StateChangeFrom, to: StateChangeTo): void { 857 | const triggerResult = this.checkTrigger(automation, automation.trigger, update, from, to); 858 | if (triggerResult === false) { 859 | this.stopTriggerForTimeout(automation); 860 | return; 861 | } 862 | if (triggerResult === null) { 863 | return; 864 | } 865 | const timeout = this.triggerForTimeouts[automation.name]; 866 | if (timeout) { 867 | this.logger.debug(`[Automations] Waiting trigger-for timeout for automation [${automation.name}]`); 868 | return; 869 | } else { 870 | this.logger.debug(`[Automations] Start automation [${automation.name}]`); 871 | } 872 | if (automation.trigger.for) { 873 | this.startTriggerForTimeout(automation); 874 | return; 875 | } 876 | this.runActionsWithConditions(automation, automation.condition, automation.action); 877 | } 878 | 879 | private findAndRun(entityId: EntityId, update: StateChangeUpdate, from: StateChangeFrom, to: StateChangeTo): void { 880 | const automations = this.eventAutomations[entityId]; 881 | if (!automations) { 882 | return; 883 | } 884 | for (const automation of automations) { 885 | this.runAutomationIfMatches(automation, update, from, to); 886 | } 887 | } 888 | 889 | private processMessage(message: MQTTMessage) 890 | { 891 | const match = message.topic.match(this.topicRegex); 892 | 893 | if (match) { 894 | 895 | for (const automations of Object.values(this.eventAutomations)) { 896 | 897 | for (const automation of automations) { 898 | 899 | if (automation.name == match[1]) { 900 | this.logger.info(`[Automations] MQTT message for [${match[1]}]: ${message.message}`); 901 | 902 | switch (message.message) { 903 | case MessagePayload.EXECUTE: 904 | this.runActions(automation, automation.action); 905 | break; 906 | } 907 | 908 | return; 909 | } 910 | } 911 | } 912 | } 913 | } 914 | 915 | async start() { 916 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 917 | this.eventBus.onStateChange(this, (data: any) => { 918 | this.findAndRun(data.entity.name, data.update, data.from, data.to); 919 | }); 920 | 921 | this.mqtt.subscribe(`${this.automationsTopic}/+`); 922 | 923 | this.eventBus.onMQTTMessage(this, (data: MQTTMessage) => { 924 | this.processMessage(data); 925 | }); 926 | 927 | } 928 | 929 | async stop() { 930 | this.logger.debug(`[Automations] Extension unloading`); 931 | for (const key of Object.keys(this.triggerForTimeouts)) { 932 | this.logger.debug(`[Automations] Clearing timeout ${key}`); 933 | clearTimeout(this.triggerForTimeouts[key]); 934 | delete this.triggerForTimeouts[key]; 935 | } 936 | for (const key of Object.keys(this.turnOffAfterTimeouts)) { 937 | this.logger.debug(`[Automations] Clearing timeout ${key}`); 938 | clearTimeout(this.turnOffAfterTimeouts[key]); 939 | delete this.turnOffAfterTimeouts[key]; 940 | } 941 | clearTimeout(this.midnightTimeout); 942 | 943 | this.logger.debug(`[Automations] Removing listeners`); 944 | this.eventBus.removeListeners(this); 945 | this.logger.debug(`[Automations] Extension unloaded`); 946 | } 947 | 948 | private payloadStringify(payload: object): string { 949 | return this.stringify(payload, false, 255, 255, 35, 220, 159, 1, '"', '"') 950 | } 951 | 952 | private stringify(payload: object, enableColors = false, colorPayload = 255, colorKey = 255, colorString = 35, colorNumber = 220, colorBoolean = 159, colorUndefined = 1, keyQuote = '', stringQuote = '\''): string { 953 | const clr = (color: number) => { 954 | return enableColors ? `\x1b[38;5;${color}m` : ''; 955 | }; 956 | const reset = () => { 957 | return enableColors ? `\x1b[0m` : ''; 958 | }; 959 | const isArray = Array.isArray(payload); 960 | let string = `${reset()}${clr(colorPayload)}` + (isArray ? '[ ' : '{ '); 961 | Object.entries(payload).forEach(([key, value], index) => { 962 | if (index > 0) { 963 | string += ', '; 964 | } 965 | let newValue = ''; 966 | newValue = value; 967 | if (typeof newValue === 'string') { 968 | newValue = `${clr(colorString)}${stringQuote}${newValue}${stringQuote}${reset()}`; 969 | } 970 | if (typeof newValue === 'number') { 971 | newValue = `${clr(colorNumber)}${newValue}${reset()}`; 972 | } 973 | if (typeof newValue === 'boolean') { 974 | newValue = `${clr(colorBoolean)}${newValue}${reset()}`; 975 | } 976 | if (typeof newValue === 'undefined') { 977 | newValue = `${clr(colorUndefined)}undefined${reset()}`; 978 | } 979 | if (typeof newValue === 'object') { 980 | newValue = this.stringify(newValue, enableColors, colorPayload, colorKey, colorString, colorNumber, colorBoolean, colorUndefined, keyQuote, stringQuote); 981 | } 982 | // new 983 | if (isArray) 984 | string += `${newValue}`; 985 | else 986 | string += `${clr(colorKey)}${keyQuote}${key}${keyQuote}${reset()}: ${newValue}`; 987 | }); 988 | return string += ` ${clr(colorPayload)}` + (isArray ? ']' : '}') + `${reset()}`; 989 | } 990 | } 991 | 992 | /* 993 | FROM HERE IS THE COPY IN TS OF SUNCALC PACKAGE https://www.npmjs.com/package/suncalc 994 | */ 995 | 996 | // 997 | // Use https://www.latlong.net/ to get latidute and longitude based on your adress 998 | // 999 | 1000 | // sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas 1001 | 1002 | // shortcuts for easier to read formulas 1003 | const PI = Math.PI, 1004 | sin = Math.sin, 1005 | cos = Math.cos, 1006 | tan = Math.tan, 1007 | asin = Math.asin, 1008 | atan = Math.atan2, 1009 | acos = Math.acos, 1010 | rad = PI / 180; 1011 | 1012 | // date/time constants and conversions 1013 | const dayMs = 1000 * 60 * 60 * 24, 1014 | J1970 = 2440588, 1015 | J2000 = 2451545; 1016 | function toJulian(date) { return date.valueOf() / dayMs - 0.5 + J1970; } 1017 | function fromJulian(j) { return new Date((j + 0.5 - J1970) * dayMs); } 1018 | function toDays(date) { return toJulian(date) - J2000; } 1019 | 1020 | // general calculations for position 1021 | const e = rad * 23.4397; // obliquity of the Earth 1022 | function rightAscension(l, b) { return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); } 1023 | function declination(l, b) { return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); } 1024 | function azimuth(H, phi, dec) { return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)); } 1025 | function altitude(H, phi, dec) { return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); } 1026 | function siderealTime(d, lw) { return rad * (280.16 + 360.9856235 * d) - lw; } 1027 | function astroRefraction(h) { 1028 | if (h < 0) // the following formula works for positive altitudes only. 1029 | h = 0; // if h = -0.08901179 a div/0 would occur. 1030 | // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 1031 | // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad: 1032 | return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179)); 1033 | } 1034 | 1035 | // general sun calculations 1036 | function solarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028 * d); } 1037 | function eclipticLongitude(M) { 1038 | const C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center 1039 | P = rad * 102.9372; // perihelion of the Earth 1040 | return M + C + P + PI; 1041 | } 1042 | 1043 | function sunCoords(d) { 1044 | const M = solarMeanAnomaly(d), 1045 | L = eclipticLongitude(M); 1046 | return { 1047 | dec: declination(L, 0), 1048 | ra: rightAscension(L, 0) 1049 | }; 1050 | } 1051 | 1052 | // calculations for sun times 1053 | const J0 = 0.0009; 1054 | function julianCycle(d, lw) { return Math.round(d - J0 - lw / (2 * PI)); } 1055 | function approxTransit(Ht, lw, n) { return J0 + (Ht + lw) / (2 * PI) + n; } 1056 | function solarTransitJ(ds, M, L) { return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L); } 1057 | function hourAngle(h, phi, d) { return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))); } 1058 | function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; } 1059 | // returns set time for the given sun altitude 1060 | function getSetJ(h, lw, phi, dec, n, M, L) { 1061 | const w = hourAngle(h, phi, dec), 1062 | a = approxTransit(w, lw, n); 1063 | return solarTransitJ(a, M, L); 1064 | } 1065 | 1066 | // moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas 1067 | function moonCoords(d) { // geocentric ecliptic coordinates of the moon 1068 | const L = rad * (218.316 + 13.176396 * d), // ecliptic longitude 1069 | M = rad * (134.963 + 13.064993 * d), // mean anomaly 1070 | F = rad * (93.272 + 13.229350 * d), // mean distance 1071 | 1072 | l = L + rad * 6.289 * sin(M), // longitude 1073 | b = rad * 5.128 * sin(F), // latitude 1074 | dt = 385001 - 20905 * cos(M); // distance to the moon in km 1075 | 1076 | return { 1077 | ra: rightAscension(l, b), 1078 | dec: declination(l, b), 1079 | dist: dt 1080 | }; 1081 | } 1082 | 1083 | function hoursLater(date, h) { 1084 | return new Date(date.valueOf() + h * dayMs / 24); 1085 | } 1086 | 1087 | class SunCalc { 1088 | // calculates sun position for a given date and latitude/longitude 1089 | // @ts-ignore: Unused method 1090 | private getPosition(date, lat, lng) { 1091 | 1092 | const lw = rad * -lng, 1093 | phi = rad * lat, 1094 | d = toDays(date), 1095 | 1096 | c = sunCoords(d), 1097 | H = siderealTime(d, lw) - c.ra; 1098 | 1099 | return { 1100 | azimuth: azimuth(H, phi, c.dec), 1101 | altitude: altitude(H, phi, c.dec) 1102 | }; 1103 | } 1104 | 1105 | // sun times configuration (angle, morning name, evening name) 1106 | private times = [ 1107 | [-0.833, 'sunrise', 'sunset'], 1108 | [-0.3, 'sunriseEnd', 'sunsetStart'], 1109 | [-6, 'dawn', 'dusk'], 1110 | [-12, 'nauticalDawn', 'nauticalDusk'], 1111 | [-18, 'nightEnd', 'night'], 1112 | [6, 'goldenHourEnd', 'goldenHour'] 1113 | ]; 1114 | 1115 | // adds a custom time to the times config 1116 | // @ts-ignore: Unused method 1117 | private addTime(angle, riseName, setName) { 1118 | this.times.push([angle, riseName, setName]); 1119 | } 1120 | 1121 | // calculates sun times for a given date, latitude/longitude, and, optionally, 1122 | // the observer height (in meters) relative to the horizon 1123 | public getTimes(date, lat, lng, height) { 1124 | height = height || 0; 1125 | 1126 | const lw = rad * -lng, 1127 | phi = rad * lat, 1128 | dh = observerAngle(height), 1129 | d = toDays(date), 1130 | n = julianCycle(d, lw), 1131 | ds = approxTransit(0, lw, n), 1132 | M = solarMeanAnomaly(ds), 1133 | L = eclipticLongitude(M), 1134 | dec = declination(L, 0), 1135 | Jnoon = solarTransitJ(ds, M, L); 1136 | let i, len, time, h0, Jset, Jrise; 1137 | const result = { 1138 | solarNoon: fromJulian(Jnoon), 1139 | nadir: fromJulian(Jnoon - 0.5) 1140 | }; 1141 | 1142 | for (i = 0, len = this.times.length; i < len; i += 1) { 1143 | time = this.times[i]; 1144 | h0 = (time[0] + dh) * rad; 1145 | Jset = getSetJ(h0, lw, phi, dec, n, M, L); 1146 | Jrise = Jnoon - (Jset - Jnoon); 1147 | result[time[1]] = fromJulian(Jrise); 1148 | result[time[2]] = fromJulian(Jset); 1149 | } 1150 | 1151 | return result; 1152 | } 1153 | 1154 | private getMoonPosition(date, lat, lng) { 1155 | const lw = rad * -lng, 1156 | phi = rad * lat, 1157 | d = toDays(date), 1158 | c = moonCoords(d), 1159 | H = siderealTime(d, lw) - c.ra; 1160 | let h = altitude(H, phi, c.dec); 1161 | // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 1162 | const pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H)); 1163 | h = h + astroRefraction(h); // altitude correction for refraction 1164 | 1165 | return { 1166 | azimuth: azimuth(H, phi, c.dec), 1167 | altitude: h, 1168 | distance: c.dist, 1169 | parallacticAngle: pa 1170 | }; 1171 | } 1172 | 1173 | // calculations for illumination parameters of the moon, 1174 | // based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and 1175 | // Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 1176 | // @ts-ignore: Unused method 1177 | private getMoonIllumination(date) { 1178 | const d = toDays(date || new Date()), 1179 | s = sunCoords(d), 1180 | m = moonCoords(d), 1181 | sdist = 149598000, // distance from Earth to Sun in km 1182 | phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)), 1183 | inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)), 1184 | angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) - 1185 | cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra)); 1186 | 1187 | return { 1188 | fraction: (1 + cos(inc)) / 2, 1189 | phase: 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI, 1190 | angle: angle 1191 | }; 1192 | } 1193 | 1194 | // calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article 1195 | // @ts-ignore: Unused method 1196 | private getMoonTimes(date, lat, lng, inUTC) { 1197 | const t = new Date(date); 1198 | if (inUTC) t.setUTCHours(0, 0, 0, 0); 1199 | else t.setHours(0, 0, 0, 0); 1200 | const hc = 0.133 * rad; 1201 | let h0 = this.getMoonPosition(t, lat, lng).altitude - hc; 1202 | let h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx; 1203 | // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set) 1204 | for (let i = 1; i <= 24; i += 2) { 1205 | h1 = this.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc; 1206 | h2 = this.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc; 1207 | a = (h0 + h2) / 2 - h1; 1208 | b = (h2 - h0) / 2; 1209 | xe = -b / (2 * a); 1210 | ye = (a * xe + b) * xe + h1; 1211 | d = b * b - 4 * a * h1; 1212 | roots = 0; 1213 | 1214 | if (d >= 0) { 1215 | dx = Math.sqrt(d) / (Math.abs(a) * 2); 1216 | x1 = xe - dx; 1217 | x2 = xe + dx; 1218 | if (Math.abs(x1) <= 1) roots++; 1219 | if (Math.abs(x2) <= 1) roots++; 1220 | if (x1 < -1) x1 = x2; 1221 | } 1222 | 1223 | if (roots === 1) { 1224 | if (h0 < 0) rise = i + x1; 1225 | else set = i + x1; 1226 | 1227 | } else if (roots === 2) { 1228 | rise = i + (ye < 0 ? x2 : x1); 1229 | set = i + (ye < 0 ? x1 : x2); 1230 | } 1231 | 1232 | if (rise && set) break; 1233 | 1234 | h0 = h2; 1235 | } 1236 | 1237 | const result = {}; 1238 | 1239 | if (rise) result[rise] = hoursLater(t, rise); 1240 | if (set) result[set] = hoursLater(t, set); 1241 | 1242 | if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true; 1243 | 1244 | return result; 1245 | } 1246 | 1247 | } 1248 | 1249 | export = AutomationsExtension; 1250 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020" 7 | ], 8 | "allowJs": false, 9 | "checkJs": false, 10 | "declaration": false, 11 | "sourceMap": false, 12 | "outDir": "./dist/", 13 | "rootDir": "./src/", 14 | "removeComments": false, 15 | "downlevelIteration": true, 16 | "isolatedModules": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "noImplicitAny": false, 20 | "strictNullChecks": true, 21 | "strictFunctionTypes": true, 22 | "noImplicitThis": true, 23 | "alwaysStrict": false, 24 | "ignoreDeprecations": "5.0", 25 | "types": [ 26 | "node" 27 | ], 28 | "noUnusedLocals": true, 29 | "noUnusedParameters": true, 30 | "noImplicitReturns": true, 31 | "noFallthroughCasesInSwitch": true, 32 | "moduleResolution": "node", 33 | "baseUrl": "./", 34 | "allowSyntheticDefaultImports": true, 35 | "experimentalDecorators": true, 36 | "strictPropertyInitialization": false, 37 | }, 38 | "watchOptions": { 39 | "watchFile": "useFsEvents", 40 | "watchDirectory": "useFsEvents", 41 | "fallbackPolling": "dynamicPriority", 42 | "synchronousWatchDirectory": true, 43 | "excludeDirectories": [ 44 | "**/node_modules", 45 | "dist" 46 | ] 47 | }, 48 | "include": [ 49 | "./src/" 50 | ], 51 | "exclude": [ 52 | "./node_modules/", 53 | "./dist/" 54 | ] 55 | } --------------------------------------------------------------------------------