├── .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 | #
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
86 |
87 |
88 | ## [1.0.9] - 2024-11-29
89 |
90 | ### Fixed
91 |
92 | - [suncalc automations]: Fix conversion in toLocaleTimeString().
93 |
94 |
95 |
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 | #
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 |
17 |
18 |
19 | Check also the https://github.com/Luligu/matterbridge-zigbee2mqtt matterbridge zigbee2mqtt plugin.
20 |
21 |
22 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------