├── .gitignore
├── .vscode
├── settings.json
└── tasks.json
├── .prettierignore
├── tsconfig.json
├── .github
├── workflows
│ └── nodejs.yml
└── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── prettier.config.js
├── .devcontainer
└── devcontainer.json
├── package.json
├── eslint.config.js
├── matterbridge.svg
├── CHANGELOG.md
├── LICENSE
├── README.md
├── bmc-button.svg
├── dist
└── automations.js
└── src
└── automations.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore compiled code
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 2,
3 | "editor.insertSpaces": true,
4 | "editor.formatOnSave": true,
5 | "editor.wordWrap": "on"
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "start",
7 | "problemMatcher": ["$tsc-watch"],
8 | "label": "npm: start",
9 | "detail": "tsc --watch",
10 | "isBackground": true
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # .prettierignore
2 | # This file specifies directories and files that should be ignored by prettier formatting
3 |
4 | # Ignore specific directories
5 | .git/
6 | coverage/
7 | dist/
8 | jest/
9 | node_modules/
10 | temp/
11 |
12 | # Ignore specific files
13 | **/*.html
14 |
15 | # Package specific directories and files that should be ignored
16 | TODO.md
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "nodenext",
5 | "moduleResolution": "nodenext",
6 | "lib": ["esnext"],
7 | "outDir": "./dist/",
8 | "rootDir": "./src/",
9 | "incremental": false,
10 | "declaration": false,
11 | "sourceMap": false,
12 | "skipLibCheck": true,
13 | "strict": true,
14 | "noImplicitAny": false
15 | },
16 | "include": ["src/**/*.ts"]
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 | branches: [master]
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 |
13 | strategy:
14 | matrix:
15 | node-version: [18.x, 20.x, 22.x, 24.x]
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 | - run: npm ci
24 | - run: npm run build
25 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | // prettier.config.js
2 |
3 | // Config for Prettier
4 |
5 | module.exports = {
6 | printWidth: 180, // default 80
7 | tabWidth: 2,
8 | useTabs: false,
9 | semi: true,
10 | singleQuote: true, // default false
11 | quoteProps: 'consistent', // default 'as-needed'
12 | jsxSingleQuote: false,
13 | trailingComma: 'all',
14 | bracketSpacing: true,
15 | bracketSameLine: false,
16 | arrowParens: 'always',
17 | requirePragma: false,
18 | insertPragma: false,
19 | proseWrap: 'preserve',
20 | endOfLine: 'lf',
21 | embeddedLanguageFormatting: 'off', // default 'auto'
22 | singleAttributePerLine: false,
23 | };
24 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
16 | 1. Go to '...'
17 | 2. Click on '....'
18 | 3. Scroll down to '....'
19 | 4. See error
20 |
21 | **Expected behavior**
22 | A clear and concise description of what you expected to happen.
23 |
24 | **Screenshots**
25 | If applicable, add screenshots to help explain your problem.
26 |
27 | **Desktop (please complete the following information):**
28 |
29 | - OS: [e.g. iOS]
30 | - Browser [e.g. chrome, safari]
31 | - Version [e.g. 22]
32 |
33 | **Smartphone (please complete the following information):**
34 |
35 | - Device: [e.g. iPhone6]
36 | - OS: [e.g. iOS8.1]
37 | - Browser [e.g. stock browser, safari]
38 | - Version [e.g. 22]
39 |
40 | **Additional context**
41 | Add any other context about the problem here.
42 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // .devcontainer/devcontainer.json
2 |
3 | // This file defines the development container configuration for a Matterbridge generic project (not a plugin).
4 | {
5 | // Name of the dev container
6 | "name": "Matterbridge Dev Container",
7 | // Use the pre-built image with Node+TypeScript
8 | "image": "mcr.microsoft.com/vscode/devcontainers/typescript-node:latest",
9 | // Mount the local matterbridge and node_modules workspace folder to the container's workspace volumes to improve performance
10 | "mounts": ["source=${localWorkspaceFolderBasename}-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"],
11 | // On startup, update npm, pnpm, install the dev of matterbridge and install the dependencies from package.json
12 | "postCreateCommand": "sudo npm install -g npm@latest pnpm@latest npm-check-updates shx && sudo chown -R node:node . && npm install && npm run build && npm outdated || true",
13 | // Give the Docker container the same name as the repository (must be unique on host)
14 | "runArgs": ["--name", "${localWorkspaceFolderBasename}"],
15 | "customizations": {
16 | "vscode": {
17 | // Extensions to install in the dev container
18 | "extensions": [
19 | "ms-vscode.vscode-typescript-next",
20 | "ms-azuretools.vscode-containers",
21 | "dbaeumer.vscode-eslint",
22 | "esbenp.prettier-vscode",
23 | "github.vscode-github-actions",
24 | "github.vscode-pull-request-github"
25 | ],
26 | // Settings for the VS Code environment
27 | "settings": {
28 | "eslint.format.enable": true,
29 | "eslint.useFlatConfig": true,
30 | "editor.formatOnSave": true,
31 | "terminal.integrated.shell.linux": "/bin/bash",
32 | "terminal.integrated.scrollback": 10000
33 | }
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zigbee2mqtt-automations",
3 | "version": "3.0.0",
4 | "description": "Automations extension for zigbee2mqtt",
5 | "author": "Luligu",
6 | "license": "Apache-2.0",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/Luligu/zigbee2mqtt-automations"
10 | },
11 | "keywords": [
12 | "matterbridge",
13 | "bridge",
14 | "zigbee",
15 | "mqtt",
16 | "mqtt-accessories",
17 | "zigbee2mqtt",
18 | "zigbee-herdsman",
19 | "zigbee-herdsman-converters",
20 | "frontend",
21 | "automations",
22 | "scenes",
23 | "extensions",
24 | "automation",
25 | "extension"
26 | ],
27 | "bugs": {
28 | "url": "https://github.com/Luligu/zigbee2mqtt-automations/issues"
29 | },
30 | "homepage": "https://github.com/Luligu/zigbee2mqtt-automations#readme",
31 | "devDependencies": {
32 | "@eslint/js": "9.35.0",
33 | "@types/node": "24.3.1",
34 | "eslint-config-prettier": "10.1.8",
35 | "eslint-plugin-import": "2.32.0",
36 | "eslint-plugin-jsdoc": "56.1.2",
37 | "eslint-plugin-n": "17.21.3",
38 | "eslint-plugin-prettier": "5.5.4",
39 | "eslint-plugin-promise": "7.2.1",
40 | "prettier": "3.6.2",
41 | "typescript": "5.9.2",
42 | "typescript-eslint": "8.43.0",
43 | "zigbee2mqtt": "2.6.1"
44 | },
45 | "scripts": {
46 | "start": "tsc --watch",
47 | "build": "tsc",
48 | "clean": "npx shx rm -rf tsconfig.tsbuildinfo dist coverage jest temp package-lock.json npm-shrinkwrap.json node_modules/* node_modules/.[!.]* node_modules/..?*",
49 | "reset": "npm run clean && npm install && npm run build",
50 | "lint": "eslint --max-warnings=0 .",
51 | "lint:fix": "eslint --fix --max-warnings=0 .",
52 | "format": "prettier --write .",
53 | "format:check": "prettier --check .",
54 | "checkDependencies": "npx npm-check-updates",
55 | "updateDependencies": "npx npm-check-updates -u && npm run reset"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // eslint.config.js
2 |
3 | // This ESLint configuration is designed for a TypeScript project.
4 |
5 | const js = require('@eslint/js');
6 | const tseslint = require('typescript-eslint');
7 | const pluginImport = require('eslint-plugin-import');
8 | const pluginN = require('eslint-plugin-n');
9 | const pluginPromise = require('eslint-plugin-promise');
10 | const pluginJsdoc = require('eslint-plugin-jsdoc');
11 | const pluginPrettierRecommended = require('eslint-plugin-prettier/recommended');
12 |
13 | module.exports = [
14 | {
15 | name: 'Global Ignores',
16 | ignores: ['dist', 'node_modules', 'coverage', 'build', 'eslint.config.js'],
17 | },
18 | js.configs.recommended,
19 | ...tseslint.configs.strict,
20 | // Comment the previous line and uncomment the following line if you want to use strict with type checking
21 | // ...tseslint.configs.strictTypeChecked,
22 | pluginImport.flatConfigs.recommended,
23 | pluginN.configs['flat/recommended-script'],
24 | pluginPromise.configs['flat/recommended'],
25 | pluginJsdoc.configs['flat/recommended'],
26 | pluginPrettierRecommended, // Prettier plugin must be the last plugin in the list
27 | {
28 | name: 'Global Configuration',
29 | languageOptions: {
30 | sourceType: 'module',
31 | ecmaVersion: 'latest',
32 | },
33 | linterOptions: {
34 | reportUnusedDisableDirectives: 'error', // Report unused eslint-disable directives
35 | reportUnusedInlineConfigs: 'error', // Report unused eslint-disable-line directives
36 | },
37 | rules: {
38 | 'no-console': 'warn', // Warn on console usage
39 | 'spaced-comment': ['error', 'always'], // Require space after comment markers
40 | 'no-unused-vars': 'warn', // Use the base rule for unused variables
41 | 'import/order': ['warn', { 'newlines-between': 'always' }],
42 | 'import/no-unresolved': 'off', // Too many false errors with named exports
43 | 'import/named': 'off', // Too many false errors with named exports
44 | 'n/prefer-node-protocol': 'error', // Prefer using 'node:' protocol for built-in modules
45 | 'n/no-extraneous-import': 'off', // Allow imports from node_modules
46 | 'n/no-unpublished-import': 'off', // Allow imports from unpublished packages
47 | 'promise/always-return': 'warn', // Ensure promises always return a value
48 | 'promise/catch-or-return': 'warn', // Ensure promises are either caught or returned
49 | 'promise/no-nesting': 'warn', // Avoid nesting promises
50 | 'jsdoc/tag-lines': ['error', 'any', { startLines: 1, endLines: 0 }], // Require a blank line before JSDoc comments
51 | 'jsdoc/check-tag-names': ['warn', { definedTags: ['created', 'contributor', 'remarks'] }], // Allow custom tags
52 | 'jsdoc/no-undefined-types': 'off',
53 | 'prettier/prettier': 'warn', // Use Prettier for formatting
54 | },
55 | },
56 | {
57 | name: 'JavaScript Source Files',
58 | files: ['**/*.js'],
59 | ...tseslint.configs.disableTypeChecked,
60 | },
61 | {
62 | name: 'TypeScript Source Files',
63 | files: ['src/**/*.ts'],
64 | ignores: ['src/**/*.test.ts', 'src/**/*.spec.ts'], // Ignore test files
65 | languageOptions: {
66 | parser: tseslint.parser,
67 | parserOptions: {
68 | project: './tsconfig.json',
69 | sourceType: 'module',
70 | ecmaVersion: 'latest',
71 | },
72 | },
73 | rules: {
74 | // Override/add rules specific to typescript files here
75 | 'no-unused-vars': 'off', // Disable base rule for unused variables in test files
76 | '@typescript-eslint/no-unused-vars': [
77 | 'error',
78 | {
79 | vars: 'all',
80 | args: 'after-used',
81 | ignoreRestSiblings: true,
82 | varsIgnorePattern: '^_', // Ignore unused variables starting with _
83 | argsIgnorePattern: '^_', // Ignore unused arguments starting with _
84 | caughtErrorsIgnorePattern: '^_', // Ignore unused caught errors starting with _
85 | },
86 | ],
87 | },
88 | },
89 | ];
90 |
--------------------------------------------------------------------------------
/matterbridge.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 | ## [3.0.0] - 2025-09-11
12 |
13 | ### Added
14 |
15 | - [scenes]: Added support for scenes. See the [readme](README.md).
16 | - [readme]: Added docs for triggering an automation and executing a scene with mqtt.
17 | - [package]: Added eslint with the usual plugins.
18 | - [package]: Added prettier.
19 | - [workflow]: Added Node CJ workflow.
20 | - [DevContainer]: Added the Matterbridge Dev Container.
21 |
22 | ### Changed
23 |
24 | - [package]: Updated dependencies.
25 | - [package]: Updated package.json.
26 | - [package]: Updated tsconfig.json.
27 |
28 |
29 |
30 |
31 |
32 | ## [2.0.5] - 2025-06-06
33 |
34 | ### Fixed
35 |
36 | - [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.
37 |
38 |
39 |
40 |
41 |
42 | ## [2.0.4] - 2025-06-04
43 |
44 | ### Fixed
45 |
46 | - [extension]: In the release 2.4.0 commit: bdb94da46e0461337f4a61b4f2a6bfa5172f608f of zigbee2mqtt, the code changed again. This fix again.
47 |
48 |
49 |
50 |
51 |
52 | ## [2.0.3] - 2025-06-03
53 |
54 | ### Fixed
55 |
56 | - [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.
57 |
58 |
59 |
60 |
61 |
62 | ## [2.0.2] - 2025-04-19
63 |
64 | ### Fixed
65 |
66 | - [suncalc]: Fixed the daily reloading of suncalc times.
67 |
68 |
69 |
70 |
71 |
72 | ## [2.0.1] - 2025-03-19
73 |
74 | ### Added
75 |
76 | - [typo]: Added the possibility to use turn_off_after with a specific payload_off. https://github.com/Luligu/zigbee2mqtt-automations/issues/16.
77 | - [examples]: Added the example "Motion in the hallway with custom payload_off" to use turn_off_after with a specific payload_off.
78 | - [examples]: Added the example "Configure daily".
79 |
80 | ### Fixed
81 |
82 | - [logger]: The logger warning level is now warning and not warn.
83 | - [typo]: Fixed a typo: https://github.com/Luligu/zigbee2mqtt-automations/pull/15. Thanks https://github.com/robvanoostenrijk.
84 |
85 |
86 |
87 |
88 |
89 | ## [2.0.0] - 2025-01-04
90 |
91 | ### Added
92 |
93 | - [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.
94 |
95 |
96 |
97 |
98 |
99 | ## [1.0.10] - 2024-11-29
100 |
101 | ### Added
102 |
103 | - [mqtt trigger]: Merged PR https://github.com/Luligu/zigbee2mqtt-automations/pull/7 (thanks https://github.com/robvanoostenrijk)
104 |
105 |
106 |
107 |
108 |
109 | ## [1.0.9] - 2024-11-29
110 |
111 | ### Fixed
112 |
113 | - [suncalc automations]: Fix conversion in toLocaleTimeString().
114 |
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
zigbee2mqtt-automations
2 |
3 | Automations and scenes extension for zigbee2mqtt (www.zigbee2mqtt.io)
4 |
5 | # Features
6 |
7 | ## Automations
8 |
9 | - Support multiple event based triggers;
10 | - Support time automations (execution at specified time);
11 | - Support suncalc automations like sunset, sunrise and others at a specified location and altitude;
12 | - Provides comprehensive logging within the zigbee2mqtt logging system for triggers, conditions and actions;
13 | - Performs thorough validation of the automation configuration file for errors (errors are logged at loading time and the erroneous automation is discarded);
14 | - Error messages and execution notifications can be displayed as pop-up messages in frontend.
15 | - User can filter automation events in the frontend by entering [Automations] in the 'Filter by text' field.
16 | - An automations can be manually triggered by publishing a message to mqtt.
17 |
18 | ## Scenes
19 |
20 | - Provides comprehensive logging within the zigbee2mqtt logging system;
21 | - A scene can be executed by an automation.
22 | - A scene can be manually executed by publishing a message to mqtt.
23 |
24 | 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.
25 |
26 |
27 |
28 |
29 |
30 | Check also the https://github.com/Luligu/matterbridge-zigbee2mqtt matterbridge zigbee2mqtt plugin.
31 |
32 |
33 |
34 |
35 |
36 | # What is an automation
37 |
38 | An automation typically consists of one or more triggers and executes one or more actions.
39 | Optionally, it can also include one or more conditions.
40 | Any trigger can start the automation while conditions must all be true for the automation to run.
41 |
42 | # What is a scene
43 |
44 | A scene consists of one or more actions (scenes can be nested).
45 |
46 | # How to install
47 |
48 | ### Method 1
49 |
50 | Download the file dist\automation.js and place it in the zigbee2mqtt\data\external_extensions directory (create the directory if it doesn't exist).
51 | Stop zigbee2mqtt, ensure it has completely stoppped, and then start it again. This method ensures all extensions are loaded.
52 |
53 | ### Method 2
54 |
55 | 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.
56 |
57 | # How to set the automations
58 |
59 | Create an automations.yaml file in the zigbee2mqtt\data directory (alongside configuration.yaml) and write your first automation (copy from the examples).
60 | Don't modify configuration.yaml.
61 |
62 | # How to set the scenes
63 |
64 | Create a scenes.yaml file in the zigbee2mqtt\data directory (alongside configuration.yaml) and write your first scene (copy from the examples).
65 | Don't modify configuration.yaml.
66 |
67 | # How to reload the automations when the file automations.yaml has been modified
68 |
69 | In the frontend go to Extensions. Select automation.js and save. The extension is reloaded and the automations.yaml ans scenes.yaml are reloaded too.
70 |
71 | # How to execute an automations publishing a command to mqtt
72 |
73 | Publish topic: **"zigbee2mqtt-automations/Name"** and a raw message: **"execute"** where "Name" is the name of your automation in automations.yaml.
74 |
75 | # How to execute a scene publishing a command to mqtt
76 |
77 | Publish topic: **"zigbee2mqtt-scenes/Name"** and raw message: **"execute"** where "Name" is the name of your scene in scenes.yaml..
78 |
79 | # Config file automations.yaml:
80 |
81 | ```
82 | :
83 | active?: ## Values: true or false Default: true (true: the automation is active)
84 | execute_once?: ## Values: true or false Default: false (true: the automatione is executed only once)
85 | trigger:
86 | ---------------------- time trigger ------------------------------
87 | time: ## Values: time string hh:mm:ss or any suncalc sunrise, sunset ...
88 | latitude?: ## Numeric latitude (mandatory for suncalc triggers) Use https://www.latlong.net/ to get latidute and longitude based on your adress
89 | longitude?: ## Numeric longitude (mandatory for suncalc triggers) Use https://www.latlong.net/ to get latidute and longitude based on your adress
90 | elevation?: ## Numeric elevation in meters for precise suncalc results Default: 0
91 | ---------------------- event trigger ------------------------------
92 | entity: ## Name of the entity (device or group friendly name) to evaluate
93 | for?: ## Number: duration in seconds for the specific attribute to remain in the triggered state
94 | state: ## Values: ON OFF
95 | attribute: ## Name of the attribute to evaluate (example: state, brightness, illuminance_lux, occupancy)
96 | equal?: ## Value of the attribute to evaluate with =
97 | not_equal?: ## Value of the attribute to evaluate with !=
98 | above?: ## Numeric value of the attribute to evaluate with >
99 | below?: ## Numeric value of the attribute to evaluate with <
100 | action: ## Value of the action to evaluate e.g. single, double, hold ...
101 | condition?:
102 | ---------------------- event condition ------------------------------
103 | entity: ## Name of the entity (device or group friendly name) to evaluate
104 | state?: ## Values: ON OFF
105 | attribute?: ## Name of the attribute (example: state, brightness, illuminance_lux, occupancy)
106 | equal?: ## Value of the attribute to evaluate with =
107 | above?: ## Numeric value of attribute to evaluate with >
108 | below?: ## Numeric value of attribute to evaluate with <
109 | ---------------------- time condition ------------------------------
110 | after?: ## Time string hh:mm:ss
111 | before?: ## Time string hh:mm:ss
112 | between?: ## Time range string hh:mm:ss-hh:mm:ss
113 | weekday?: ## Day string or array of day strings: 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'
114 | action:
115 | entity: ## Name of the entity (device or group friendly name) to send the payload to
116 | payload: ## Values: turn_on, turn_off, toggle or any supported attributes in an object or indented on the next rows
117 | (example: { state: OFF, brightness: 254, color: { r: 0, g: 255, b: 0 } })
118 | scene: ## Name of the scene to run
119 | logger?: ## Values: debug info warning error. Default: debug. The action will be logged on z2m logger with the specified logging level
120 | turn_off_after?: ## Number: seconds to wait before turning off entity. Will send a turn_off to the entity.
121 | payload_off?: ## Values: any supported attributes in an object. Will use payload_off instead of { state: "OFF" }.
122 | ```
123 |
124 | # Trigger examples:
125 |
126 | ### The automation is run at the specified time
127 |
128 | ```yaml
129 | Turn off at 23:
130 | trigger:
131 | time: 23:00:00
132 | ```
133 |
134 | ### The automation is run at sunset time at the coordinates and elevation specified
135 |
136 | ```yaml
137 | Sunset:
138 | trigger:
139 | time: sunset
140 | latitude: 48.858372
141 | longitude: 2.294481
142 | elevation: 330
143 | ```
144 |
145 | ### The automation is run when contact change to false (the contact is opened) for the device Contact sensor
146 |
147 | ```yaml
148 | Contact sensor OPENED:
149 | trigger:
150 | entity: Contact sensor
151 | attribute: contact
152 | equal: false
153 | ```
154 |
155 | # Time condition examples:
156 |
157 | ### The automation is run only on monday, tuesday and friday and only after 08:30 and before 22:30
158 |
159 | ```yaml
160 | condition:
161 | after: 08:30:00
162 | before: 22:30:00
163 | weekday: ["mon", "tue", "fri"]
164 | ```
165 |
166 | ### The automation is run only between 08:00 and 20:00
167 |
168 | ```yaml
169 | condition:
170 | between: 08:00:00-20:00:00
171 | ```
172 |
173 | ### The automation is run only after 20:00 and before 08:00
174 |
175 | ```yaml
176 | condition:
177 | between: 20:00:00-08:00:00
178 | ```
179 |
180 | # Event condition examples:
181 |
182 | ### The automation is run only if 'At home' is ON
183 |
184 | ```yaml
185 | condition:
186 | entity: At home
187 | state: ON
188 | ```
189 |
190 | ### The automation is run only if the illuminance_lux attribute of 'Light sensor' is below 100.
191 |
192 | ```yaml
193 | condition:
194 | entity: Light sensor
195 | attribute: illuminance_lux
196 | below: 100
197 | ```
198 |
199 | ### The automation is run only if 'At home' is ON and 'Is night' is OFF. For multiple entity conditions entity must be indented.
200 |
201 | ```yaml
202 | condition:
203 | - entity: At home
204 | state: ON
205 | - entity: Is night
206 | state: OFF
207 | ```
208 |
209 | # Time and event condition examples:
210 |
211 | ### For multiple conditions after, before, weekday and entity must be indented
212 |
213 | ```yaml
214 | condition:
215 | - after: 08:00:00
216 | - before: 22:30:00
217 | - weekday: ["mon", "tue", "thu", "fri"]
218 | - entity: Is night
219 | state: ON
220 | - entity: Is dark
221 | state: ON
222 | ```
223 |
224 | # Action examples:
225 |
226 | ### Payload can be a string (turn_on, turn_off and toggle or an object)
227 |
228 | ```yaml
229 | action:
230 | - entity: Miboxer RGB led controller
231 | payload: { brightness: 255, color: { r: 0, g: 0, b: 255 }, transition: 5 }
232 | - entity: Moes RGB CCT led controller
233 | payload: { brightness: 255, color_temp: 500, transition: 10 }
234 | - entity: Aqara switch T1
235 | payload: turn_on
236 | turn_off_after: 10
237 | - entity: Moes switch double
238 | payload: { state_l1: ON }
239 | ```
240 |
241 | ### Instead of specify an object it's possible to indent each attribute
242 |
243 | ```yaml
244 | action:
245 | - entity: Moes switch double
246 | payload:
247 | state_l1: ON
248 | ```
249 |
250 | # Complete automation examples
251 |
252 | ### If there was a zigbee2mqtt installation in the top of the Eiffel Tower this would be the perfect automation.
253 |
254 | ```yaml
255 | Sunrise:
256 | trigger:
257 | time: sunrise
258 | latitude: 48.858372
259 | longitude: 2.294481
260 | elevation: 330
261 | action:
262 | - entity: Moes RGB CCT led controller
263 | payload: { state: OFF }
264 | - entity: Is night
265 | payload: { state: OFF }
266 | ```
267 |
268 | ```yaml
269 | Sunset:
270 | trigger:
271 | time: sunset
272 | latitude: 48.858372
273 | longitude: 2.294481
274 | elevation: 330
275 | action:
276 | - entity: Moes RGB CCT led controller
277 | payload: { state: ON }
278 | - entity: Is night
279 | payload: { state: ON }
280 | ```
281 |
282 | ### 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)
283 |
284 | ```yaml
285 | Light sensor below 50lux for 60s:
286 | trigger:
287 | entity: Light sensor
288 | attribute: illuminance_lux
289 | below: 50
290 | for: 60
291 | action:
292 | entity: Is dark
293 | payload: turn_on
294 | ```
295 |
296 | ```yaml
297 | Light sensor above 60lux for 60s:
298 | trigger:
299 | entity: Light sensor
300 | attribute: illuminance_lux
301 | above: 60
302 | for: 60
303 | action:
304 | entity: Is dark
305 | payload: turn_off
306 | ```
307 |
308 | ### These automations turn on and off the device 'Aqara switch T1'
309 |
310 | ```yaml
311 | Contact sensor OPENED:
312 | trigger:
313 | entity: Contact sensor
314 | attribute: contact
315 | equal: false
316 | condition:
317 | entity: At home
318 | state: ON
319 | action:
320 | entity: Aqara switch T1
321 | logger: info
322 | payload: turn_on
323 | ```
324 |
325 | ```yaml
326 | Contact sensor CLOSED:
327 | trigger:
328 | entity: Contact sensor
329 | attribute: contact
330 | state: true
331 | for: 5
332 | action:
333 | entity: Aqara switch T1
334 | logger: info
335 | payload:
336 | state: OFF
337 | ```
338 |
339 | ### Turn on the light for 60 secs after occupancy is detected by 'Motion sensor'
340 |
341 | ```yaml
342 | Motion in the hallway:
343 | active: true
344 | trigger:
345 | entity: Hallway motion sensor
346 | attribute: occupancy
347 | equal: true
348 | action:
349 | entity: Hallway light
350 | payload: turn_on
351 | turn_off_after: 60
352 | logger: info
353 | ```
354 |
355 | ### Turn on the light for 60 secs after occupancy is detected by 'Hallway motion sensor'. Configure the payload_off to send.
356 |
357 | ```yaml
358 | Motion in the hallway with custom payload_off:
359 | active: true
360 | trigger:
361 | entity: Hallway motion sensor
362 | attribute: occupancy
363 | equal: true
364 | action:
365 | entity: Hallway light
366 | payload: turn_on
367 | turn_off_after: 60
368 | payload_off: { state: "OFF" }
369 | logger: info
370 | ```
371 |
372 | ### Creates a daily routine to configure any devices that sometimes loose the correct setting.
373 |
374 | ```yaml
375 | Configure daily:
376 | active: true
377 | trigger:
378 | time: 20:00:00
379 | action:
380 | - entity: Bathroom Lights
381 | payload: { switch_type: "momentary" }
382 | logger: info
383 | - entity: Bathroom Leds
384 | payload: { switch_type: "momentary" }
385 | logger: info
386 | ```
387 |
388 | # Config file scenes.yaml:
389 |
390 | ## Scene example
391 |
392 | ```yaml
393 | Guest room off:
394 | - entity: Guest room Floor leds
395 | payload: { state: "OFF" }
396 | - entity: Guest room Lights
397 | payload: { state: "OFF" }
398 | - entity: Guest room Desk light strip
399 | payload: { state: "OFF" }
400 | - entity: Guest room Wardrobe light strip
401 | payload: { state: "OFF" }
402 | ```
403 |
404 | ## How to run it from an automation:
405 |
406 | In the action section, add scene: Name
407 |
408 | ```yaml
409 | Guest room Scenes 1_double:
410 | trigger:
411 | entity: Guest room Scenes switch
412 | action: 1_double
413 | action:
414 | - scene: Guest room off
415 | ```
416 |
417 | # Sponsor
418 |
419 | If you like the extension and want to sponsor it:
420 |
421 | - https://www.paypal.com/paypalme/LuliguGitHub
422 | - https://www.buymeacoffee.com/luligugithub
423 |
424 |
425 |
426 |
427 |
428 | # Bug report and feature request
429 |
430 | https://github.com/Luligu/zigbee2mqtt-automations/issues
431 |
432 | # Credits
433 |
434 | Sun calculations are derived entirely from suncalc package https://www.npmjs.com/package/suncalc.
435 | This extension was originally forked from https://github.com/Anonym-tsk/zigbee2mqtt-extensions.
436 |
--------------------------------------------------------------------------------
/bmc-button.svg:
--------------------------------------------------------------------------------
1 |
23 |
--------------------------------------------------------------------------------
/dist/automations.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | /**
3 | * @description This file contains the class AutomationsExtension and its definitions.
4 | * @file automations.ts
5 | * @author Luca Liguori
6 | * @created 2023-10-15
7 | * @version 3.0.0
8 | * @license Apache-2.0
9 | *
10 | * Copyright 2023, 2024, 2025, 2026, 2027 Luca Liguori.
11 | *
12 | * Licensed under the Apache License, Version 2.0 (the "License");
13 | * you may not use this file except in compliance with the License.
14 | * You may obtain a copy of the License at
15 | *
16 | * http://www.apache.org/licenses/LICENSE-2.0
17 | *
18 | * Unless required by applicable law or agreed to in writing, software
19 | * distributed under the License is distributed on an "AS IS" BASIS,
20 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 | * See the License for the specific language governing permissions and
22 | * limitations under the License.
23 | */
24 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
25 | /* eslint-disable @typescript-eslint/no-dynamic-delete */
26 | /* eslint-disable @typescript-eslint/no-empty-object-type */
27 | /* eslint-disable @typescript-eslint/no-require-imports */
28 | /* eslint-disable @typescript-eslint/ban-ts-comment */
29 | /* eslint-disable no-console */
30 | /* eslint-disable jsdoc/require-jsdoc */
31 | /* eslint-disable jsdoc/require-param-type */
32 | /* eslint-disable jsdoc/require-param-description */
33 | /* eslint-disable jsdoc/require-returns */
34 | /* eslint-disable import/no-duplicates */
35 | const node_buffer_1 = require("node:buffer");
36 | // These packages are defined inside zigbee2mqtt and so they are not available here to import!
37 | // The external extensions are now loaded from a temp directory, we use require to load them from where we know they are
38 | const path = require('node:path');
39 | const utilPath = path.join(require.main?.path, 'dist', 'util');
40 | const joinPath = require(path.join(utilPath, 'data')).default.joinPath;
41 | const readIfExists = require(path.join(utilPath, 'yaml')).default.readIfExists;
42 | function toArray(item) {
43 | return Array.isArray(item) ? item : [item];
44 | }
45 | var ConfigSunCalc;
46 | (function (ConfigSunCalc) {
47 | ConfigSunCalc["SOLAR_NOON"] = "solarNoon";
48 | ConfigSunCalc["NADIR"] = "nadir";
49 | ConfigSunCalc["SUNRISE"] = "sunrise";
50 | ConfigSunCalc["SUNSET"] = "sunset";
51 | ConfigSunCalc["SUNRISE_END"] = "sunriseEnd";
52 | ConfigSunCalc["SUNSET_START"] = "sunsetStart";
53 | ConfigSunCalc["DAWN"] = "dawn";
54 | ConfigSunCalc["DUSK"] = "dusk";
55 | ConfigSunCalc["NAUTICAL_DAWN"] = "nauticalDawn";
56 | ConfigSunCalc["NAUTICAL_DUSK"] = "nauticalDusk";
57 | ConfigSunCalc["NIGHT_END"] = "nightEnd";
58 | ConfigSunCalc["NIGHT"] = "night";
59 | ConfigSunCalc["GOLDEN_HOUR_END"] = "goldenHourEnd";
60 | ConfigSunCalc["GOLDEN_HOUR"] = "goldenHour";
61 | })(ConfigSunCalc || (ConfigSunCalc = {}));
62 | var ConfigState;
63 | (function (ConfigState) {
64 | ConfigState["ON"] = "ON";
65 | ConfigState["OFF"] = "OFF";
66 | ConfigState["TOGGLE"] = "TOGGLE";
67 | })(ConfigState || (ConfigState = {}));
68 | var ConfigPayload;
69 | (function (ConfigPayload) {
70 | ConfigPayload["TOGGLE"] = "toggle";
71 | ConfigPayload["TURN_ON"] = "turn_on";
72 | ConfigPayload["TURN_OFF"] = "turn_off";
73 | })(ConfigPayload || (ConfigPayload = {}));
74 | var MessagePayload;
75 | (function (MessagePayload) {
76 | MessagePayload["EXECUTE"] = "execute";
77 | })(MessagePayload || (MessagePayload = {}));
78 | class InternalLogger {
79 | debug(message, ...args) {
80 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;247m${message}\x1b[0m`, ...args);
81 | }
82 | warning(message, ...args) {
83 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;220m${message}\x1b[0m`, ...args);
84 | }
85 | info(message, ...args) {
86 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;255m${message}\x1b[0m`, ...args);
87 | }
88 | error(message, ...args) {
89 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;9m${message}\x1b[0m`, ...args);
90 | }
91 | }
92 | class AutomationsExtension {
93 | zigbee;
94 | mqtt;
95 | state;
96 | publishEntityState;
97 | eventBus;
98 | enableDisableExtension;
99 | restartCallback;
100 | addExtension;
101 | settings;
102 | logger;
103 | mqttBaseTopic;
104 | automationsTopic;
105 | scenesTopic;
106 | automationsTopicRegex;
107 | scenesTopicRegex;
108 | scenes = {};
109 | eventAutomations = {};
110 | timeAutomations = {};
111 | triggerForTimeouts;
112 | turnOffAfterTimeouts;
113 | midnightTimeout;
114 | log;
115 | constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, logger) {
116 | this.zigbee = zigbee;
117 | this.mqtt = mqtt;
118 | this.state = state;
119 | this.publishEntityState = publishEntityState;
120 | this.eventBus = eventBus;
121 | this.enableDisableExtension = enableDisableExtension;
122 | this.restartCallback = restartCallback;
123 | this.addExtension = addExtension;
124 | this.settings = settings;
125 | this.logger = logger;
126 | this.log = new InternalLogger();
127 | this.mqttBaseTopic = settings.get().mqtt.base_topic;
128 | this.triggerForTimeouts = {};
129 | this.turnOffAfterTimeouts = {};
130 | this.automationsTopic = 'zigbee2mqtt-automations';
131 | this.scenesTopic = 'zigbee2mqtt-scenes';
132 | // eslint-disable-next-line no-useless-escape
133 | this.automationsTopicRegex = new RegExp(`^${this.automationsTopic}\/(.*)`);
134 | // eslint-disable-next-line no-useless-escape
135 | this.scenesTopicRegex = new RegExp(`^${this.scenesTopic}\/(.*)`);
136 | this.logger.info(`[Automations] Loading automation.js`);
137 | if (!this.parseConfig())
138 | return;
139 | /*
140 | this.log.info(`Event automation:`);
141 | Object.keys(this.eventAutomations).forEach(key => {
142 | const eventAutomationArray = this.eventAutomations[key];
143 | eventAutomationArray.forEach(eventAutomation => {
144 | this.log.info(`- key: #${key}# automation: ${this.stringify(eventAutomation, true)}`);
145 | });
146 | });
147 | */
148 | // this.log.info(`Time automation:`);
149 | Object.keys(this.timeAutomations).forEach((key) => {
150 | const timeAutomationArray = this.timeAutomations[key];
151 | timeAutomationArray.forEach((timeAutomation) => {
152 | // this.log.info(`- key: #${key}# automation: ${this.stringify(timeAutomation, true)}`);
153 | this.startTimeTriggers(key, timeAutomation);
154 | });
155 | });
156 | this.startMidnightTimeout();
157 | this.logger.info(`[Automations] Automation.js loaded`);
158 | }
159 | parseConfig() {
160 | let configScenes = {};
161 | try {
162 | configScenes = readIfExists(joinPath('scenes.yaml')) || {};
163 | this.scenes = configScenes;
164 | Object.entries(this.scenes).forEach(([key, configScene]) => {
165 | const actions = toArray(configScene);
166 | this.logger.info(`[Scenes] Registering scene [${key}]`);
167 | for (const action of actions) {
168 | if (action.entity) {
169 | if (!this.zigbee.resolveEntity(action.entity)) {
170 | this.logger.error(`[Scenes] Config validation error for [${key}]: entity #${action.entity}# not found`);
171 | }
172 | if (!action.payload) {
173 | this.logger.error(`[Scenes] Config validation error for [${key}]: entity #${action.entity}# payload not found`);
174 | }
175 | }
176 | else if (action.scene) {
177 | this.logger.info(`[Scenes] - scene #${action.scene}#`);
178 | }
179 | }
180 | });
181 | }
182 | catch (_error) {
183 | this.logger.info(`[Automations] Error loading file scenes.yaml: see stderr for explanation`);
184 | }
185 | let configAutomations = {};
186 | try {
187 | // configAutomations = (yaml.readIfExists(data.joinPath('automations.yaml')) || {}) as ConfigAutomations;
188 | configAutomations = (readIfExists(joinPath('automations.yaml')) || {});
189 | }
190 | catch (error) {
191 | this.logger.error(`[Automations] Error loading file automations.yaml: see stderr for explanation`);
192 | console.log(error);
193 | return false;
194 | }
195 | Object.entries(configAutomations).forEach(([key, configAutomation]) => {
196 | const actions = toArray(configAutomation.action);
197 | const conditions = configAutomation.condition ? toArray(configAutomation.condition) : [];
198 | const triggers = toArray(configAutomation.trigger);
199 | // Check automation
200 | if (configAutomation.active === false) {
201 | this.logger.info(`[Automations] Automation [${key}] not registered since active is false`);
202 | return;
203 | }
204 | if (!configAutomation.trigger) {
205 | this.logger.error(`[Automations] Config validation error for [${key}]: no triggers defined`);
206 | return;
207 | }
208 | if (!configAutomation.action) {
209 | this.logger.error(`[Automations] Config validation error for [${key}]: no actions defined`);
210 | return;
211 | }
212 | // Check triggers
213 | for (const trigger of triggers) {
214 | if (!trigger.time && !trigger.entity) {
215 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity not defined`);
216 | return;
217 | }
218 | if (!trigger.time && !this.zigbee.resolveEntity(trigger.entity)) {
219 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${trigger.entity}# not found`);
220 | return;
221 | }
222 | }
223 | // Check actions
224 | for (const action of actions) {
225 | if (!action.entity && !action.scene) {
226 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity or action scene not defined`);
227 | return;
228 | }
229 | if (action.entity && !this.zigbee.resolveEntity(action.entity)) {
230 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity #${action.entity}# not found`);
231 | return;
232 | }
233 | if (action.entity && !action.payload) {
234 | this.logger.error(`[Automations] Config validation error for [${key}]: action payload not defined`);
235 | return;
236 | }
237 | if (action.scene && !this.scenes[action.scene]) {
238 | this.logger.error(`[Automations] Config validation error for [${key}]: action scene #${action.scene}# not found`);
239 | return;
240 | }
241 | }
242 | // Check conditions
243 | for (const condition of conditions) {
244 | if (!condition.entity &&
245 | !condition.after &&
246 | !condition.before &&
247 | !condition.between &&
248 | !condition.weekday) {
249 | this.logger.error(`[Automations] Config validation error for [${key}]: condition unknown`);
250 | return;
251 | }
252 | if (condition.entity && !this.zigbee.resolveEntity(condition.entity)) {
253 | this.logger.error(`[Automations] Config validation error for [${key}]: condition entity #${condition.entity}# not found`);
254 | return;
255 | }
256 | }
257 | for (const trigger of triggers) {
258 | if (trigger.time !== undefined) {
259 | const timeTrigger = trigger;
260 | this.logger.info(`[Automations] Registering time automation [${key}] trigger: ${timeTrigger.time}`);
261 | const suncalcs = Object.values(ConfigSunCalc);
262 | if (suncalcs.includes(timeTrigger.time)) {
263 | if (!timeTrigger.latitude || !timeTrigger.longitude) {
264 | this.logger.error(`[Automations] Config validation error for [${key}]: latitude and longitude are mandatory for ${trigger.time}`);
265 | return;
266 | }
267 | const suncalc = new SunCalc();
268 | const times = suncalc.getTimes(new Date(), timeTrigger.latitude, timeTrigger.longitude, timeTrigger.elevation ? timeTrigger.elevation : 0);
269 | 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}`);
270 | this.log.debug(`[Automations] For latitude:${timeTrigger.latitude} longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0} suncalc are:\n`, times);
271 | const options = {
272 | hour12: false,
273 | hour: '2-digit',
274 | minute: '2-digit',
275 | second: '2-digit',
276 | };
277 | const time = times[trigger.time].toLocaleTimeString('en-GB', options);
278 | this.log.debug(`[Automations] Registering time automation [${key}] trigger: ${time}`);
279 | if (!this.timeAutomations[time])
280 | this.timeAutomations[time] = [];
281 | this.timeAutomations[time].push({
282 | name: key,
283 | execute_once: configAutomation.execute_once,
284 | trigger: timeTrigger,
285 | action: actions,
286 | condition: conditions,
287 | });
288 | }
289 | else if (this.matchTimeString(timeTrigger.time)) {
290 | if (!this.timeAutomations[timeTrigger.time])
291 | this.timeAutomations[timeTrigger.time] = [];
292 | this.timeAutomations[timeTrigger.time].push({
293 | name: key,
294 | execute_once: configAutomation.execute_once,
295 | trigger: timeTrigger,
296 | action: actions,
297 | condition: conditions,
298 | });
299 | }
300 | else {
301 | this.logger.error(`[Automations] Config validation error for [${key}]: time syntax error for ${trigger.time}`);
302 | return;
303 | }
304 | }
305 | if (trigger.entity !== undefined) {
306 | const eventTrigger = trigger;
307 | if (!this.zigbee.resolveEntity(eventTrigger.entity)) {
308 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${eventTrigger.entity}# not found`);
309 | return;
310 | }
311 | this.logger.info(`[Automations] Registering event automation [${key}] trigger: entity #${eventTrigger.entity}#`);
312 | const entities = toArray(eventTrigger.entity);
313 | for (const entity of entities) {
314 | if (!this.eventAutomations[entity]) {
315 | this.eventAutomations[entity] = [];
316 | }
317 | this.eventAutomations[entity].push({
318 | name: key,
319 | execute_once: configAutomation.execute_once,
320 | trigger: eventTrigger,
321 | action: actions,
322 | condition: conditions,
323 | });
324 | }
325 | }
326 | } // for (const trigger of triggers)
327 | });
328 | return true;
329 | }
330 | /**
331 | * Check a time string and return a Date or undefined if error
332 | *
333 | * @param timeString
334 | */
335 | matchTimeString(timeString) {
336 | if (timeString.length !== 8)
337 | return undefined;
338 | const match = timeString.match(/(\d{2}):(\d{2}):(\d{2})/);
339 | if (match && parseInt(match[1], 10) <= 23 && parseInt(match[2], 10) <= 59 && parseInt(match[3], 10) <= 59) {
340 | const time = new Date();
341 | time.setHours(parseInt(match[1], 10));
342 | time.setMinutes(parseInt(match[2], 10));
343 | time.setSeconds(parseInt(match[3], 10));
344 | return time;
345 | }
346 | return undefined;
347 | }
348 | /**
349 | * Start a timeout in the first second of tomorrow date.
350 | * It also reload the suncalc times for the next day.
351 | * The timeout callback then will start the time triggers for tomorrow and start again a timeout for the next day.
352 | */
353 | startMidnightTimeout() {
354 | const now = new Date();
355 | const timeEvent = new Date();
356 | timeEvent.setHours(23);
357 | timeEvent.setMinutes(59);
358 | timeEvent.setSeconds(59);
359 | this.logger.debug(`[Automations] Set timeout to reload for time automations`);
360 | this.midnightTimeout = setTimeout(() => {
361 | this.logger.info(`[Automations] Run timeout to reload time automations`);
362 | const newTimeAutomations = {};
363 | const suncalcs = Object.values(ConfigSunCalc);
364 | Object.keys(this.timeAutomations).forEach((key) => {
365 | const timeAutomationArray = this.timeAutomations[key];
366 | timeAutomationArray.forEach((timeAutomation) => {
367 | if (suncalcs.includes(timeAutomation.trigger.time)) {
368 | if (!timeAutomation.trigger.latitude || !timeAutomation.trigger.longitude)
369 | return;
370 | const suncalc = new SunCalc();
371 | const times = suncalc.getTimes(new Date(), timeAutomation.trigger.latitude, timeAutomation.trigger.longitude, timeAutomation.trigger.elevation ?? 0);
372 | // this.log.info(`Key:[${key}] For latitude:${timeAutomation.trigger.latitude} longitude:${timeAutomation.trigger.longitude} elevation:${timeAutomation.trigger.elevation ?? 0} suncalcs are:\n`, times);
373 | const options = {
374 | hour12: false,
375 | hour: '2-digit',
376 | minute: '2-digit',
377 | second: '2-digit',
378 | };
379 | const time = times[timeAutomation.trigger.time].toLocaleTimeString('en-GB', options);
380 | // this.log.info(`Registering suncalc time automation at time [${time}] for:`, timeAutomation);
381 | this.logger.info(`[Automations] Registering suncalc time automation at time [${time}] for: ${this.stringify(timeAutomation)}`);
382 | if (!newTimeAutomations[time])
383 | newTimeAutomations[time] = [];
384 | newTimeAutomations[time].push(timeAutomation);
385 | this.startTimeTriggers(time, timeAutomation);
386 | }
387 | else {
388 | // this.log.info(`Registering normal time automation at time [${key}] for:`, timeAutomation);
389 | this.logger.info(`[Automations] Registering normal time automation at time [${key}] for: ${this.stringify(timeAutomation)}`);
390 | if (!newTimeAutomations[key])
391 | newTimeAutomations[key] = [];
392 | newTimeAutomations[key].push(timeAutomation);
393 | this.startTimeTriggers(key, timeAutomation);
394 | }
395 | });
396 | });
397 | this.timeAutomations = newTimeAutomations;
398 | this.startMidnightTimeout();
399 | }, timeEvent.getTime() - now.getTime() + 2000);
400 | this.midnightTimeout.unref();
401 | }
402 | /**
403 | * Take the key of TimeAutomations that is a string like hh:mm:ss, convert it in a Date object of today
404 | * and set the timer if not already passed for today.
405 | * The timeout callback then will run the automations
406 | *
407 | * @param key
408 | * @param automation
409 | */
410 | startTimeTriggers(key, automation) {
411 | const now = new Date();
412 | const timeEvent = this.matchTimeString(key);
413 | if (timeEvent !== undefined) {
414 | if (timeEvent.getTime() > now.getTime()) {
415 | this.logger.debug(`[Automations] Set timeout at ${timeEvent.toLocaleString()} for [${automation.name}]`);
416 | const timeout = setTimeout(() => {
417 | delete this.triggerForTimeouts[automation.name];
418 | this.logger.debug(`[Automations] Timeout for [${automation.name}]`);
419 | this.runActionsWithConditions(automation, automation.condition, automation.action);
420 | }, timeEvent.getTime() - now.getTime());
421 | timeout.unref();
422 | this.triggerForTimeouts[automation.name] = timeout;
423 | }
424 | else {
425 | this.logger.debug(`[Automations] Timeout at ${timeEvent.toLocaleString()} is passed for [${automation.name}]`);
426 | }
427 | }
428 | else {
429 | this.logger.error(`[Automations] Timeout config error at ${key} for [${automation.name}]`);
430 | }
431 | }
432 | /**
433 | * null - return
434 | * false - return and stop timer
435 | * true - start the automation
436 | *
437 | * @param automation
438 | * @param configTrigger
439 | * @param update
440 | * @param from
441 | * @param to
442 | */
443 | checkTrigger(automation, configTrigger, update, from, to) {
444 | let trigger;
445 | let attribute;
446 | let result;
447 | let actions;
448 | // this.log.warning(`[Automations] Trigger check [${automation.name}] update: ${this.stringify(update)} from: ${this.stringify(from)} to: ${this.stringify(to)}`);
449 | if (configTrigger.action !== undefined) {
450 | if (!Object.prototype.hasOwnProperty.call(update, 'action')) {
451 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no 'action' in update for #${configTrigger.entity}#`);
452 | return null;
453 | }
454 | trigger = configTrigger;
455 | actions = toArray(trigger.action);
456 | result = actions.includes(update.action);
457 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger is ${result} for #${configTrigger.entity}# action(s): ${this.stringify(actions)}`);
458 | return result;
459 | }
460 | else if (configTrigger.attribute !== undefined) {
461 | trigger = configTrigger;
462 | attribute = trigger.attribute;
463 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) {
464 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`);
465 | return null;
466 | }
467 | if (from[attribute] === to[attribute]) {
468 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`);
469 | return null;
470 | }
471 | if (typeof trigger.equal !== 'undefined' || typeof trigger.state !== 'undefined') {
472 | const value = trigger.state !== undefined ? trigger.state : trigger.equal;
473 | if (to[attribute] !== value) {
474 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${value} for #${configTrigger.entity}#`);
475 | return false;
476 | }
477 | if (from[attribute] === value) {
478 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already = ${value} for #${configTrigger.entity}#`);
479 | return null;
480 | }
481 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger equal/state ${value} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `);
482 | }
483 | if (typeof trigger.not_equal !== 'undefined') {
484 | if (to[attribute] === trigger.not_equal) {
485 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' = ${trigger.not_equal} for #${configTrigger.entity}#`);
486 | return false;
487 | }
488 | if (from[attribute] !== trigger.not_equal) {
489 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already != ${trigger.not_equal} for #${configTrigger.entity}#`);
490 | return null;
491 | }
492 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger not equal ${trigger.not_equal} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `);
493 | }
494 | if (typeof trigger.above !== 'undefined') {
495 | if (to[attribute] <= trigger.above) {
496 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' <= ${trigger.above} for #${configTrigger.entity}#`);
497 | return false;
498 | }
499 | if (from[attribute] > trigger.above) {
500 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already > ${trigger.above} for #${configTrigger.entity}#`);
501 | return null;
502 | }
503 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger above ${trigger.above} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `);
504 | }
505 | if (typeof trigger.below !== 'undefined') {
506 | if (to[attribute] >= trigger.below) {
507 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' >= ${trigger.below} for #${configTrigger.entity}#`);
508 | return false;
509 | }
510 | if (from[attribute] < trigger.below) {
511 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already < ${trigger.below} for #${configTrigger.entity}#`);
512 | return null;
513 | }
514 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger below ${trigger.below} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `);
515 | }
516 | return true;
517 | }
518 | else if (configTrigger.state !== undefined) {
519 | trigger = configTrigger;
520 | attribute = 'state';
521 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) {
522 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`);
523 | return null;
524 | }
525 | if (from[attribute] === to[attribute]) {
526 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`);
527 | return null;
528 | }
529 | if (to[attribute] !== trigger.state) {
530 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${trigger.state} for #${configTrigger.entity}#`);
531 | return null;
532 | }
533 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger state ${trigger.state} is true for #${configTrigger.entity}# state is ${to[attribute]}`);
534 | return true;
535 | }
536 | return false;
537 | }
538 | checkCondition(automation, condition) {
539 | let timeResult = true;
540 | let eventResult = true;
541 | if (condition.after ||
542 | condition.before ||
543 | condition.between ||
544 | condition.weekday) {
545 | timeResult = this.checkTimeCondition(automation, condition);
546 | }
547 | if (condition.entity) {
548 | eventResult = this.checkEntityCondition(automation, condition);
549 | }
550 | return timeResult && eventResult;
551 | }
552 | // Return false if condition is false
553 | checkTimeCondition(automation, condition) {
554 | // this.logger.info(`[Automations] checkTimeCondition [${automation.name}]: ${this.stringify(condition)}`);
555 | const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
556 | const now = new Date();
557 | if (condition.weekday && !condition.weekday.includes(days[now.getDay()])) {
558 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for weekday: ${this.stringify(condition.weekday)} since today is ${days[now.getDay()]}`);
559 | return false;
560 | }
561 | if (condition.before) {
562 | const time = this.matchTimeString(condition.before);
563 | if (time !== undefined) {
564 | if (now.getTime() > time.getTime()) {
565 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for before: ${condition.before} since now is ${now.toLocaleTimeString()}`);
566 | return false;
567 | }
568 | }
569 | else {
570 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: before #${condition.before}# ignoring condition`);
571 | }
572 | }
573 | if (condition.after) {
574 | const time = this.matchTimeString(condition.after);
575 | if (time !== undefined) {
576 | if (now.getTime() < time.getTime()) {
577 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for after: ${condition.after} since now is ${now.toLocaleTimeString()}`);
578 | return false;
579 | }
580 | }
581 | else {
582 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: after #${condition.after}# ignoring condition`);
583 | }
584 | }
585 | if (condition.between) {
586 | const [startTimeStr, endTimeStr] = condition.between.split('-');
587 | const startTime = this.matchTimeString(startTimeStr);
588 | const endTime = this.matchTimeString(endTimeStr);
589 | if (startTime !== undefined && endTime !== undefined) {
590 | // Internal time span: between: 08:00:00-20:00:00
591 | if (startTime.getTime() < endTime.getTime()) {
592 | if (now.getTime() < startTime.getTime() || now.getTime() > endTime.getTime()) {
593 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`);
594 | return false;
595 | }
596 | }
597 | // External time span: between: 20:00:00-06:00:00
598 | else if (startTime.getTime() > endTime.getTime()) {
599 | if (now.getTime() < startTime.getTime() && now.getTime() > endTime.getTime()) {
600 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`);
601 | return false;
602 | }
603 | }
604 | }
605 | else {
606 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: between #${condition.between}# ignoring condition`);
607 | }
608 | }
609 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is true for ${this.stringify(condition)}`);
610 | return true;
611 | }
612 | // Return false if condition is false
613 | checkEntityCondition(automation, condition) {
614 | if (!condition.entity) {
615 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: condition entity not specified`);
616 | return false;
617 | }
618 | const entity = this.zigbee.resolveEntity(condition.entity);
619 | if (!entity) {
620 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: entity #${condition.entity}# not found`);
621 | return false;
622 | }
623 | const attribute = condition.attribute || 'state';
624 | const value = this.state.get(entity)[attribute];
625 | if (condition.state !== undefined && value !== condition.state) {
626 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not '${condition.state}'`);
627 | return false;
628 | }
629 | if (condition.attribute !== undefined && condition.equal !== undefined && value !== condition.equal) {
630 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not equal '${condition.equal}'`);
631 | return false;
632 | }
633 | if (condition.attribute !== undefined && condition.below !== undefined && value >= condition.below) {
634 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not below '${condition.below}'`);
635 | return false;
636 | }
637 | if (condition.attribute !== undefined && condition.above !== undefined && value <= condition.above) {
638 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not above '${condition.above}'`);
639 | return false;
640 | }
641 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is true for entity #${condition.entity}# attribute '${attribute}' is '${value}'`);
642 | return true;
643 | }
644 | runActions(automation, actions) {
645 | for (const action of actions) {
646 | // Check if action is scene and run it
647 | if (action.scene && typeof action.scene === 'string') {
648 | this.log.warning(`Executing scene: ${action.scene}`);
649 | this.runActions({ name: action.scene }, this.scenes[action.scene]);
650 | continue;
651 | }
652 | // Check if action is entity and run it
653 | const entity = this.zigbee.resolveEntity(action.entity);
654 | if (!entity) {
655 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`);
656 | continue;
657 | }
658 | let data;
659 | // this.log.warn('Payload:', typeof action.payload, action.payload)
660 | if (typeof action.payload === 'string') {
661 | if (action.payload === ConfigPayload.TURN_ON) {
662 | data = { state: ConfigState.ON };
663 | }
664 | else if (action.payload === ConfigPayload.TURN_OFF) {
665 | data = { state: ConfigState.OFF };
666 | }
667 | else if (action.payload === ConfigPayload.TOGGLE) {
668 | data = { state: ConfigState.TOGGLE };
669 | }
670 | else {
671 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`);
672 | return;
673 | }
674 | }
675 | else if (typeof action.payload === 'object') {
676 | data = action.payload;
677 | }
678 | else {
679 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`);
680 | return;
681 | }
682 | if (action.logger === 'info')
683 | this.logger.info(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
684 | else if (action.logger === 'warning')
685 | this.logger.warning(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
686 | else if (action.logger === 'error')
687 | this.logger.error(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
688 | else
689 | this.logger.debug(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
690 | // this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, Buffer.from(this.payloadStringify(data)));
691 | // This is even faster cause we skip one passage.
692 | this.eventBus.emitMQTTMessage({
693 | topic: `${this.mqttBaseTopic}/${entity.name}/set`,
694 | message: this.payloadStringify(data),
695 | });
696 | if (action.turn_off_after) {
697 | this.startActionTurnOffTimeout(automation, action);
698 | }
699 | } // End for (const action of actions)
700 | if (automation.execute_once === true) {
701 | this.removeAutomation(automation.name);
702 | }
703 | }
704 | // Remove automation that has execute_once: true
705 | removeAutomation(name) {
706 | this.logger.debug(`[Automations] Uregistering automation [${name}]`);
707 | // this.log.warning(`Uregistering automation [${name}]`);
708 | Object.keys(this.eventAutomations).forEach((entity) => {
709 | // this.log.warning(`Entity: #${entity}#`);
710 | Object.values(this.eventAutomations[entity]).forEach((eventAutomation, index) => {
711 | if (eventAutomation.name === name) {
712 | // this.log.warning(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`);
713 | this.eventAutomations[entity].splice(index, 1);
714 | }
715 | else {
716 | // this.log.info(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`);
717 | }
718 | });
719 | });
720 | Object.keys(this.timeAutomations).forEach((now) => {
721 | // this.log.warning(`Time: #${now}#`);
722 | Object.values(this.timeAutomations[now]).forEach((timeAutomation, index) => {
723 | if (timeAutomation.name === name) {
724 | // this.log.warning(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`);
725 | this.timeAutomations[now].splice(index, 1);
726 | }
727 | else {
728 | // this.log.info(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`);
729 | }
730 | });
731 | });
732 | }
733 | // Stop the turn_off_after timeout
734 | stopActionTurnOffTimeout(automation, action) {
735 | const timeout = this.turnOffAfterTimeouts[automation.name + action.entity];
736 | if (timeout) {
737 | this.logger.debug(`[Automations] Stop turn_off_after timeout for automation [${automation.name}]`);
738 | clearTimeout(timeout);
739 | delete this.turnOffAfterTimeouts[automation.name + action.entity];
740 | }
741 | }
742 | // Start the turn_off_after timeout
743 | startActionTurnOffTimeout(automation, action) {
744 | this.stopActionTurnOffTimeout(automation, action);
745 | this.logger.debug(`[Automations] Start ${action.turn_off_after} seconds turn_off_after timeout for automation [${automation.name}]`);
746 | const timeout = setTimeout(() => {
747 | delete this.turnOffAfterTimeouts[automation.name + action.entity];
748 | // this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}]`);
749 | const entity = this.zigbee.resolveEntity(action.entity);
750 | if (!entity) {
751 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`);
752 | this.stopActionTurnOffTimeout(automation, action);
753 | return;
754 | }
755 | const data = action.payload_off ?? { state: ConfigState.OFF };
756 | if (action.logger === 'info')
757 | this.logger.info(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
758 | else if (action.logger === 'warning')
759 | this.logger.warning(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
760 | else if (action.logger === 'error')
761 | this.logger.error(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
762 | else
763 | this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
764 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, node_buffer_1.Buffer.from(this.payloadStringify(data)));
765 | }, action.turn_off_after * 1000);
766 | timeout.unref();
767 | this.turnOffAfterTimeouts[automation.name + action.entity] = timeout;
768 | }
769 | runActionsWithConditions(automation, conditions, actions) {
770 | for (const condition of conditions) {
771 | // this.log.warning(`runActionsWithConditions: conditon: ${this.stringify(condition)}`);
772 | if (!this.checkCondition(automation, condition)) {
773 | return;
774 | }
775 | }
776 | this.runActions(automation, actions);
777 | }
778 | // Stop the trigger_for timeout
779 | stopTriggerForTimeout(automation) {
780 | const timeout = this.triggerForTimeouts[automation.name];
781 | if (timeout) {
782 | // this.log.debug(`Stop timeout for automation [${automation.name}] trigger: ${this.stringify(automation.trigger)}`);
783 | this.logger.debug(`[Automations] Stop trigger-for timeout for automation [${automation.name}]`);
784 | clearTimeout(timeout);
785 | delete this.triggerForTimeouts[automation.name];
786 | }
787 | }
788 | // Start the trigger_for timeout
789 | startTriggerForTimeout(automation) {
790 | if (automation.trigger.for === undefined || automation.trigger.for === 0) {
791 | this.logger.error(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout error for automation [${automation.name}]`);
792 | return;
793 | }
794 | this.logger.debug(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout for automation [${automation.name}]`);
795 | const timeout = setTimeout(() => {
796 | delete this.triggerForTimeouts[automation.name];
797 | this.logger.debug(`[Automations] Trigger-for timeout for automation [${automation.name}]`);
798 | this.runActionsWithConditions(automation, automation.condition, automation.action);
799 | }, automation.trigger.for * 1000);
800 | timeout.unref();
801 | this.triggerForTimeouts[automation.name] = timeout;
802 | }
803 | runAutomationIfMatches(automation, update, from, to) {
804 | const triggerResult = this.checkTrigger(automation, automation.trigger, update, from, to);
805 | if (triggerResult === false) {
806 | this.stopTriggerForTimeout(automation);
807 | return;
808 | }
809 | if (triggerResult === null) {
810 | return;
811 | }
812 | const timeout = this.triggerForTimeouts[automation.name];
813 | if (timeout) {
814 | this.logger.debug(`[Automations] Waiting trigger-for timeout for automation [${automation.name}]`);
815 | return;
816 | }
817 | else {
818 | this.logger.debug(`[Automations] Start automation [${automation.name}]`);
819 | }
820 | if (automation.trigger.for) {
821 | this.startTriggerForTimeout(automation);
822 | return;
823 | }
824 | this.runActionsWithConditions(automation, automation.condition, automation.action);
825 | }
826 | findAndRun(entityId, update, from, to) {
827 | const automations = this.eventAutomations[entityId];
828 | if (!automations) {
829 | return;
830 | }
831 | for (const automation of automations) {
832 | this.runAutomationIfMatches(automation, update, from, to);
833 | }
834 | }
835 | // Process MQTT messages private message for automations.
836 | // Publish: topic "zigbee2mqtt-automations/" with raw message "execute"
837 | // Publish: topic "zigbee2mqtt-scenes/" with raw message "execute"
838 | processMessage(message) {
839 | const automationsMatch = message.topic.match(this.automationsTopicRegex);
840 | if (automationsMatch) {
841 | for (const automations of Object.values(this.eventAutomations)) {
842 | for (const automation of automations) {
843 | if (automation.name == automationsMatch[1]) {
844 | this.logger.info(`[Automations] MQTT message for [${automationsMatch[1]}]: "${message.message}"`);
845 | switch (message.message.trim()) {
846 | case MessagePayload.EXECUTE:
847 | this.log.warning(`Executing automation "${automation.name}": ${this.stringify(automation.action, true)}`);
848 | this.runActions(automation, automation.action);
849 | break;
850 | }
851 | return;
852 | }
853 | }
854 | }
855 | }
856 | const scenesMatch = message.topic.match(this.scenesTopicRegex);
857 | if (scenesMatch) {
858 | Object.entries(this.scenes).forEach(([key]) => {
859 | if (key == scenesMatch[1]) {
860 | this.logger.info(`[Scenes] MQTT message for [${scenesMatch[1]}]: "${message.message}"`);
861 | switch (message.message.trim()) {
862 | case MessagePayload.EXECUTE:
863 | this.log.warning(`Executing scene "${key}": ${this.stringify(this.scenes[key], true)}`);
864 | this.runActions({ name: key }, this.scenes[key]);
865 | break;
866 | }
867 | }
868 | });
869 | }
870 | }
871 | async start() {
872 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
873 | this.eventBus.onStateChange(this, (data) => {
874 | this.findAndRun(data.entity.name, data.update, data.from, data.to);
875 | });
876 | this.mqtt.subscribe(`${this.automationsTopic}/+`);
877 | this.mqtt.subscribe(`${this.scenesTopic}/+`);
878 | this.eventBus.onMQTTMessage(this, (data) => {
879 | this.processMessage(data);
880 | });
881 | }
882 | async stop() {
883 | this.logger.debug(`[Automations] Extension unloading`);
884 | for (const key of Object.keys(this.triggerForTimeouts)) {
885 | this.logger.debug(`[Automations] Clearing timeout ${key}`);
886 | clearTimeout(this.triggerForTimeouts[key]);
887 | delete this.triggerForTimeouts[key];
888 | }
889 | for (const key of Object.keys(this.turnOffAfterTimeouts)) {
890 | this.logger.debug(`[Automations] Clearing timeout ${key}`);
891 | clearTimeout(this.turnOffAfterTimeouts[key]);
892 | delete this.turnOffAfterTimeouts[key];
893 | }
894 | clearTimeout(this.midnightTimeout);
895 | this.logger.debug(`[Automations] Removing listeners`);
896 | this.eventBus.removeListeners(this);
897 | this.logger.debug(`[Automations] Extension unloaded`);
898 | }
899 | payloadStringify(payload) {
900 | return this.stringify(payload, false, 255, 255, 35, 220, 159, 1, '"', '"');
901 | }
902 | stringify(payload, enableColors = false, colorPayload = 255, colorKey = 255, colorString = 35, colorNumber = 220, colorBoolean = 159, colorUndefined = 1, keyQuote = '', stringQuote = "'") {
903 | const clr = (color) => {
904 | return enableColors ? `\x1b[38;5;${color}m` : '';
905 | };
906 | const reset = () => {
907 | return enableColors ? `\x1b[0m` : '';
908 | };
909 | const isArray = Array.isArray(payload);
910 | let string = `${reset()}${clr(colorPayload)}` + (isArray ? '[ ' : '{ ');
911 | Object.entries(payload).forEach(([key, value], index) => {
912 | if (index > 0) {
913 | string += ', ';
914 | }
915 | let newValue = '';
916 | newValue = value;
917 | if (typeof newValue === 'string') {
918 | newValue = `${clr(colorString)}${stringQuote}${newValue}${stringQuote}${reset()}`;
919 | }
920 | if (typeof newValue === 'number') {
921 | newValue = `${clr(colorNumber)}${newValue}${reset()}`;
922 | }
923 | if (typeof newValue === 'boolean') {
924 | newValue = `${clr(colorBoolean)}${newValue}${reset()}`;
925 | }
926 | if (typeof newValue === 'undefined') {
927 | newValue = `${clr(colorUndefined)}undefined${reset()}`;
928 | }
929 | if (typeof newValue === 'object') {
930 | newValue = this.stringify(newValue, enableColors, colorPayload, colorKey, colorString, colorNumber, colorBoolean, colorUndefined, keyQuote, stringQuote);
931 | }
932 | // new
933 | if (isArray)
934 | string += `${newValue}`;
935 | else
936 | string += `${clr(colorKey)}${keyQuote}${key}${keyQuote}${reset()}: ${newValue}`;
937 | });
938 | return (string += ` ${clr(colorPayload)}` + (isArray ? ']' : '}') + `${reset()}`);
939 | }
940 | }
941 | /*
942 | FROM HERE IS THE COPY IN TS OF SUNCALC PACKAGE https://www.npmjs.com/package/suncalc
943 | */
944 | //
945 | // Use https://www.latlong.net/ to get latidute and longitude based on your adress
946 | //
947 | // sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas
948 | // shortcuts for easier to read formulas
949 | 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;
950 | // date/time constants and conversions
951 | const dayMs = 1000 * 60 * 60 * 24, J1970 = 2440588, J2000 = 2451545;
952 | function toJulian(date) {
953 | return date.valueOf() / dayMs - 0.5 + J1970;
954 | }
955 | function fromJulian(j) {
956 | return new Date((j + 0.5 - J1970) * dayMs);
957 | }
958 | function toDays(date) {
959 | return toJulian(date) - J2000;
960 | }
961 | // general calculations for position
962 | const e = rad * 23.4397; // obliquity of the Earth
963 | function rightAscension(l, b) {
964 | return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l));
965 | }
966 | function declination(l, b) {
967 | return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l));
968 | }
969 | function azimuth(H, phi, dec) {
970 | return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi));
971 | }
972 | function altitude(H, phi, dec) {
973 | return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H));
974 | }
975 | function siderealTime(d, lw) {
976 | return rad * (280.16 + 360.9856235 * d) - lw;
977 | }
978 | function astroRefraction(h) {
979 | if (h < 0)
980 | // the following formula works for positive altitudes only.
981 | h = 0; // if h = -0.08901179 a div/0 would occur.
982 | // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
983 | // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad:
984 | return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179));
985 | }
986 | // general sun calculations
987 | function solarMeanAnomaly(d) {
988 | return rad * (357.5291 + 0.98560028 * d);
989 | }
990 | function eclipticLongitude(M) {
991 | const C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center
992 | P = rad * 102.9372; // perihelion of the Earth
993 | return M + C + P + PI;
994 | }
995 | function sunCoords(d) {
996 | const M = solarMeanAnomaly(d), L = eclipticLongitude(M);
997 | return {
998 | dec: declination(L, 0),
999 | ra: rightAscension(L, 0),
1000 | };
1001 | }
1002 | // calculations for sun times
1003 | const J0 = 0.0009;
1004 | function julianCycle(d, lw) {
1005 | return Math.round(d - J0 - lw / (2 * PI));
1006 | }
1007 | function approxTransit(Ht, lw, n) {
1008 | return J0 + (Ht + lw) / (2 * PI) + n;
1009 | }
1010 | function solarTransitJ(ds, M, L) {
1011 | return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L);
1012 | }
1013 | function hourAngle(h, phi, d) {
1014 | return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d)));
1015 | }
1016 | function observerAngle(height) {
1017 | return (-2.076 * Math.sqrt(height)) / 60;
1018 | }
1019 | // returns set time for the given sun altitude
1020 | function getSetJ(h, lw, phi, dec, n, M, L) {
1021 | const w = hourAngle(h, phi, dec), a = approxTransit(w, lw, n);
1022 | return solarTransitJ(a, M, L);
1023 | }
1024 | // moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas
1025 | function moonCoords(d) {
1026 | // geocentric ecliptic coordinates of the moon
1027 | const L = rad * (218.316 + 13.176396 * d), // ecliptic longitude
1028 | M = rad * (134.963 + 13.064993 * d), // mean anomaly
1029 | F = rad * (93.272 + 13.22935 * d), // mean distance
1030 | l = L + rad * 6.289 * sin(M), // longitude
1031 | b = rad * 5.128 * sin(F), // latitude
1032 | dt = 385001 - 20905 * cos(M); // distance to the moon in km
1033 | return {
1034 | ra: rightAscension(l, b),
1035 | dec: declination(l, b),
1036 | dist: dt,
1037 | };
1038 | }
1039 | function hoursLater(date, h) {
1040 | return new Date(date.valueOf() + (h * dayMs) / 24);
1041 | }
1042 | class SunCalc {
1043 | // calculates sun position for a given date and latitude/longitude
1044 | // @ts-ignore: Unused method
1045 | getPosition(date, lat, lng) {
1046 | const lw = rad * -lng, phi = rad * lat, d = toDays(date), c = sunCoords(d), H = siderealTime(d, lw) - c.ra;
1047 | return {
1048 | azimuth: azimuth(H, phi, c.dec),
1049 | altitude: altitude(H, phi, c.dec),
1050 | };
1051 | }
1052 | // sun times configuration (angle, morning name, evening name)
1053 | times = [
1054 | [-0.833, 'sunrise', 'sunset'],
1055 | [-0.3, 'sunriseEnd', 'sunsetStart'],
1056 | [-6, 'dawn', 'dusk'],
1057 | [-12, 'nauticalDawn', 'nauticalDusk'],
1058 | [-18, 'nightEnd', 'night'],
1059 | [6, 'goldenHourEnd', 'goldenHour'],
1060 | ];
1061 | // adds a custom time to the times config
1062 | // @ts-ignore: Unused method
1063 | addTime(angle, riseName, setName) {
1064 | this.times.push([angle, riseName, setName]);
1065 | }
1066 | // calculates sun times for a given date, latitude/longitude, and, optionally,
1067 | // the observer height (in meters) relative to the horizon
1068 | getTimes(date, lat, lng, height) {
1069 | height = height || 0;
1070 | 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);
1071 | let i, len, time, h0, Jset, Jrise;
1072 | const result = {
1073 | solarNoon: fromJulian(Jnoon),
1074 | nadir: fromJulian(Jnoon - 0.5),
1075 | };
1076 | for (i = 0, len = this.times.length; i < len; i += 1) {
1077 | time = this.times[i];
1078 | h0 = (time[0] + dh) * rad;
1079 | Jset = getSetJ(h0, lw, phi, dec, n, M, L);
1080 | Jrise = Jnoon - (Jset - Jnoon);
1081 | result[time[1]] = fromJulian(Jrise);
1082 | result[time[2]] = fromJulian(Jset);
1083 | }
1084 | return result;
1085 | }
1086 | getMoonPosition(date, lat, lng) {
1087 | const lw = rad * -lng, phi = rad * lat, d = toDays(date), c = moonCoords(d), H = siderealTime(d, lw) - c.ra;
1088 | let h = altitude(H, phi, c.dec);
1089 | // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
1090 | const pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H));
1091 | h = h + astroRefraction(h); // altitude correction for refraction
1092 | return {
1093 | azimuth: azimuth(H, phi, c.dec),
1094 | altitude: h,
1095 | distance: c.dist,
1096 | parallacticAngle: pa,
1097 | };
1098 | }
1099 | // calculations for illumination parameters of the moon,
1100 | // based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and
1101 | // Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
1102 | // @ts-ignore: Unused method
1103 | getMoonIllumination(date) {
1104 | const d = toDays(date || new Date()), s = sunCoords(d), m = moonCoords(d), sdist = 149598000, // distance from Earth to Sun in km
1105 | 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) - cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra));
1106 | return {
1107 | fraction: (1 + cos(inc)) / 2,
1108 | phase: 0.5 + (0.5 * inc * (angle < 0 ? -1 : 1)) / Math.PI,
1109 | angle: angle,
1110 | };
1111 | }
1112 | // calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
1113 | // @ts-ignore: Unused method
1114 | getMoonTimes(date, lat, lng, inUTC) {
1115 | const t = new Date(date);
1116 | if (inUTC)
1117 | t.setUTCHours(0, 0, 0, 0);
1118 | else
1119 | t.setHours(0, 0, 0, 0);
1120 | const hc = 0.133 * rad;
1121 | let h0 = this.getMoonPosition(t, lat, lng).altitude - hc;
1122 | let h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx;
1123 | // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
1124 | for (let i = 1; i <= 24; i += 2) {
1125 | h1 = this.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
1126 | h2 = this.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
1127 | a = (h0 + h2) / 2 - h1;
1128 | b = (h2 - h0) / 2;
1129 | xe = -b / (2 * a);
1130 | ye = (a * xe + b) * xe + h1;
1131 | d = b * b - 4 * a * h1;
1132 | roots = 0;
1133 | if (d >= 0) {
1134 | dx = Math.sqrt(d) / (Math.abs(a) * 2);
1135 | x1 = xe - dx;
1136 | x2 = xe + dx;
1137 | if (Math.abs(x1) <= 1)
1138 | roots++;
1139 | if (Math.abs(x2) <= 1)
1140 | roots++;
1141 | if (x1 < -1)
1142 | x1 = x2;
1143 | }
1144 | if (roots === 1) {
1145 | if (h0 < 0)
1146 | rise = i + x1;
1147 | else
1148 | set = i + x1;
1149 | }
1150 | else if (roots === 2) {
1151 | rise = i + (ye < 0 ? x2 : x1);
1152 | set = i + (ye < 0 ? x1 : x2);
1153 | }
1154 | if (rise && set)
1155 | break;
1156 | h0 = h2;
1157 | }
1158 | const result = {};
1159 | if (rise)
1160 | result[rise] = hoursLater(t, rise);
1161 | if (set)
1162 | result[set] = hoursLater(t, set);
1163 | if (!rise && !set)
1164 | result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
1165 | return result;
1166 | }
1167 | }
1168 | module.exports = AutomationsExtension;
1169 |
--------------------------------------------------------------------------------
/src/automations.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description This file contains the class AutomationsExtension and its definitions.
3 | * @file automations.ts
4 | * @author Luca Liguori
5 | * @created 2023-10-15
6 | * @version 3.0.0
7 | * @license Apache-2.0
8 | *
9 | * Copyright 2023, 2024, 2025, 2026, 2027 Luca Liguori.
10 | *
11 | * Licensed under the Apache License, Version 2.0 (the "License");
12 | * you may not use this file except in compliance with the License.
13 | * You may obtain a copy of the License at
14 | *
15 | * http://www.apache.org/licenses/LICENSE-2.0
16 | *
17 | * Unless required by applicable law or agreed to in writing, software
18 | * distributed under the License is distributed on an "AS IS" BASIS,
19 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 | * See the License for the specific language governing permissions and
21 | * limitations under the License.
22 | */
23 |
24 | /* eslint-disable @typescript-eslint/no-non-null-assertion */
25 | /* eslint-disable @typescript-eslint/no-dynamic-delete */
26 | /* eslint-disable @typescript-eslint/no-empty-object-type */
27 | /* eslint-disable @typescript-eslint/no-require-imports */
28 | /* eslint-disable @typescript-eslint/ban-ts-comment */
29 | /* eslint-disable no-console */
30 | /* eslint-disable jsdoc/require-jsdoc */
31 | /* eslint-disable jsdoc/require-param-type */
32 | /* eslint-disable jsdoc/require-param-description */
33 | /* eslint-disable jsdoc/require-returns */
34 | /* eslint-disable import/no-duplicates */
35 |
36 | import { Buffer } from 'node:buffer';
37 |
38 | import type Zigbee from 'zigbee2mqtt/dist/zigbee.d.ts';
39 | import type MQTT from 'zigbee2mqtt/dist/mqtt.d.ts';
40 | import type State from 'zigbee2mqtt/dist/state.d.ts';
41 | import type Device from 'zigbee2mqtt/dist/model/device.d.ts';
42 | import type Group from 'zigbee2mqtt/dist/model/device.d.ts';
43 | import type EventBus from 'zigbee2mqtt/dist/eventBus.d.ts';
44 | import type Extension from 'zigbee2mqtt/dist/extension/extension.d.ts';
45 | import type Settings from 'zigbee2mqtt/dist/util/settings.d.ts';
46 | import type Logger from 'zigbee2mqtt/dist/util/logger.d.ts';
47 |
48 | // These packages are defined inside zigbee2mqtt and so they are not available here to import!
49 | // The external extensions are now loaded from a temp directory, we use require to load them from where we know they are
50 | const path = require('node:path');
51 |
52 | const utilPath = path.join(require.main?.path, 'dist', 'util');
53 | const joinPath = require(path.join(utilPath, 'data')).default.joinPath;
54 | const readIfExists = require(path.join(utilPath, 'yaml')).default.readIfExists;
55 |
56 | type StateChangeReason = 'publishDebounce' | 'groupOptimistic' | 'lastSeenChanged' | 'publishCached' | 'publishThrottle';
57 |
58 | function toArray(item: T | T[]): T[] {
59 | return Array.isArray(item) ? item : [item];
60 | }
61 |
62 | enum ConfigSunCalc {
63 | SOLAR_NOON = 'solarNoon',
64 | NADIR = 'nadir',
65 | SUNRISE = 'sunrise',
66 | SUNSET = 'sunset',
67 | SUNRISE_END = 'sunriseEnd',
68 | SUNSET_START = 'sunsetStart',
69 | DAWN = 'dawn',
70 | DUSK = 'dusk',
71 | NAUTICAL_DAWN = 'nauticalDawn',
72 | NAUTICAL_DUSK = 'nauticalDusk',
73 | NIGHT_END = 'nightEnd',
74 | NIGHT = 'night',
75 | GOLDEN_HOUR_END = 'goldenHourEnd',
76 | GOLDEN_HOUR = 'goldenHour',
77 | }
78 |
79 | enum ConfigState {
80 | ON = 'ON',
81 | OFF = 'OFF',
82 | TOGGLE = 'TOGGLE',
83 | }
84 |
85 | enum ConfigPayload {
86 | TOGGLE = 'toggle',
87 | TURN_ON = 'turn_on',
88 | TURN_OFF = 'turn_off',
89 | }
90 |
91 | enum MessagePayload {
92 | EXECUTE = 'execute',
93 | }
94 |
95 | type ConfigStateType = string;
96 | type ConfigPayloadType = string | number | boolean;
97 | type ConfigActionType = string;
98 | type ConfigAttributeType = string;
99 | type ConfigAttributeValueType = string | number | boolean;
100 |
101 | type StateChangeType = string | number | boolean;
102 | type StateChangeUpdate = Record;
103 | type StateChangeFrom = Record;
104 | type StateChangeTo = Record;
105 |
106 | type TriggerForType = number;
107 | type TurnOffAfterType = number;
108 | type ExecuteOnceType = boolean;
109 | type ActiveType = boolean;
110 | type TimeStringType = string; // e.g. "15:05:00"
111 | type LoggerType = string;
112 |
113 | interface ConfigTrigger {
114 | entity: EntityId | EntityId[];
115 | time: TimeStringType;
116 | }
117 |
118 | interface ConfigTimeTrigger extends ConfigTrigger {
119 | time: TimeStringType;
120 | latitude?: number;
121 | longitude?: number;
122 | elevation?: number;
123 | }
124 |
125 | interface ConfigEventTrigger extends ConfigTrigger {
126 | entity: EntityId | EntityId[];
127 | for?: TriggerForType;
128 | action?: ConfigActionType | ConfigActionType[];
129 | state?: ConfigStateType | ConfigStateType[];
130 | attribute?: ConfigAttributeType;
131 | equal?: ConfigAttributeValueType;
132 | not_equal?: ConfigAttributeValueType;
133 | above?: number;
134 | below?: number;
135 | }
136 |
137 | interface ConfigActionTrigger extends ConfigEventTrigger {
138 | action: ConfigActionType | ConfigActionType[];
139 | }
140 |
141 | interface ConfigStateTrigger extends ConfigEventTrigger {
142 | state: ConfigStateType | ConfigStateType[];
143 | }
144 |
145 | interface ConfigAttributeTrigger extends ConfigEventTrigger {
146 | attribute: ConfigAttributeType;
147 | equal?: ConfigAttributeValueType;
148 | not_equal?: ConfigAttributeValueType;
149 | above?: number;
150 | below?: number;
151 | }
152 |
153 | type ConfigActionPayload = Record;
154 |
155 | interface ConfigAction {
156 | // entity type action
157 | entity?: EntityId;
158 | payload?: ConfigActionPayload;
159 | payload_off?: ConfigActionPayload;
160 | turn_off_after?: TurnOffAfterType;
161 | logger?: LoggerType;
162 | // scene type action
163 | scene?: SceneId; // scene name
164 | }
165 |
166 | interface ConfigCondition {}
167 |
168 | interface ConfigEntityCondition extends ConfigCondition {
169 | entity: EntityId;
170 | state?: ConfigStateType;
171 | attribute?: ConfigAttributeType;
172 | equal?: ConfigAttributeValueType;
173 | not_equal?: ConfigAttributeValueType;
174 | above?: number;
175 | below?: number;
176 | }
177 |
178 | interface ConfigTimeCondition extends ConfigCondition {
179 | after?: TimeStringType;
180 | before?: TimeStringType;
181 | between?: TimeStringType;
182 | weekday?: string[];
183 | }
184 |
185 | interface ConfigSceneAction {
186 | entity: EntityId;
187 | payload: ConfigActionPayload;
188 | logger?: LoggerType;
189 | scene?: SceneId; // scene name
190 | }
191 |
192 | // Yaml defined scenes
193 | type ConfigScenes = {
194 | [key: string]: ConfigSceneAction | ConfigSceneAction[];
195 | };
196 |
197 | // Yaml defined automations
198 | type ConfigAutomations = {
199 | [key: string]: {
200 | execute_once?: ExecuteOnceType;
201 | active?: ActiveType;
202 | trigger: ConfigTrigger | ConfigTrigger[];
203 | action: ConfigAction | ConfigAction[];
204 | condition?: ConfigCondition | ConfigCondition[];
205 | };
206 | };
207 |
208 | // Internal event based automations
209 | type EventAutomation = {
210 | name: string;
211 | execute_once?: ExecuteOnceType;
212 | trigger: ConfigEventTrigger;
213 | condition: ConfigCondition[];
214 | action: ConfigAction[];
215 | };
216 |
217 | type EntityId = string;
218 | type SceneId = string;
219 |
220 | type EventAutomations = {
221 | [key: EntityId]: EventAutomation[];
222 | };
223 |
224 | // Internal time based automations
225 | type TimeAutomation = {
226 | name: string;
227 | execute_once?: ExecuteOnceType;
228 | trigger: ConfigTimeTrigger;
229 | condition: ConfigCondition[];
230 | action: ConfigAction[];
231 | };
232 |
233 | type TimeId = string;
234 |
235 | type TimeAutomations = {
236 | [key: TimeId]: TimeAutomation[];
237 | };
238 |
239 | type MQTTMessage = {
240 | topic: string;
241 | message: string;
242 | };
243 |
244 | class InternalLogger {
245 | debug(message: string, ...args: unknown[]): void {
246 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;247m${message}\x1b[0m`, ...args);
247 | }
248 |
249 | warning(message: string, ...args: unknown[]): void {
250 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;220m${message}\x1b[0m`, ...args);
251 | }
252 |
253 | info(message: string, ...args: unknown[]): void {
254 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;255m${message}\x1b[0m`, ...args);
255 | }
256 |
257 | error(message: string, ...args: unknown[]): void {
258 | console.log(`\x1b[46m\x1b[97m[Automations]\x1b[0m \x1b[38;5;9m${message}\x1b[0m`, ...args);
259 | }
260 | }
261 |
262 | class AutomationsExtension {
263 | private readonly mqttBaseTopic: string;
264 | private readonly automationsTopic: string;
265 | private readonly scenesTopic: string;
266 | private readonly automationsTopicRegex: RegExp;
267 | private readonly scenesTopicRegex: RegExp;
268 | private scenes: ConfigScenes = {};
269 | private readonly eventAutomations: EventAutomations = {};
270 | private timeAutomations: TimeAutomations = {};
271 | private readonly triggerForTimeouts: Record;
272 | private readonly turnOffAfterTimeouts: Record;
273 | private midnightTimeout: NodeJS.Timeout | undefined;
274 | private readonly log: InternalLogger;
275 |
276 | constructor(
277 | protected zigbee: Zigbee,
278 | protected mqtt: MQTT,
279 | protected state: State,
280 | protected publishEntityState: (entity: Device | Group, payload: Record, stateChangeReason?: StateChangeReason) => Promise,
281 | protected eventBus: EventBus,
282 | protected enableDisableExtension: (enable: boolean, name: string) => Promise,
283 | protected restartCallback: () => Promise,
284 | protected addExtension: (extension: Extension) => Promise,
285 | protected settings: typeof Settings,
286 | protected logger: typeof Logger,
287 | ) {
288 | this.log = new InternalLogger();
289 | this.mqttBaseTopic = settings.get().mqtt.base_topic;
290 | this.triggerForTimeouts = {};
291 | this.turnOffAfterTimeouts = {};
292 | this.automationsTopic = 'zigbee2mqtt-automations';
293 | this.scenesTopic = 'zigbee2mqtt-scenes';
294 | // eslint-disable-next-line no-useless-escape
295 | this.automationsTopicRegex = new RegExp(`^${this.automationsTopic}\/(.*)`);
296 | // eslint-disable-next-line no-useless-escape
297 | this.scenesTopicRegex = new RegExp(`^${this.scenesTopic}\/(.*)`);
298 |
299 | this.logger.info(`[Automations] Loading automation.js`);
300 |
301 | if (!this.parseConfig()) return;
302 |
303 | /*
304 | this.log.info(`Event automation:`);
305 | Object.keys(this.eventAutomations).forEach(key => {
306 | const eventAutomationArray = this.eventAutomations[key];
307 | eventAutomationArray.forEach(eventAutomation => {
308 | this.log.info(`- key: #${key}# automation: ${this.stringify(eventAutomation, true)}`);
309 | });
310 | });
311 | */
312 | // this.log.info(`Time automation:`);
313 | Object.keys(this.timeAutomations).forEach((key) => {
314 | const timeAutomationArray = this.timeAutomations[key];
315 | timeAutomationArray.forEach((timeAutomation) => {
316 | // this.log.info(`- key: #${key}# automation: ${this.stringify(timeAutomation, true)}`);
317 | this.startTimeTriggers(key, timeAutomation);
318 | });
319 | });
320 |
321 | this.startMidnightTimeout();
322 |
323 | this.logger.info(`[Automations] Automation.js loaded`);
324 | }
325 |
326 | private parseConfig(): boolean {
327 | let configScenes: ConfigScenes = {};
328 | try {
329 | configScenes = readIfExists(joinPath('scenes.yaml')) || {};
330 | this.scenes = configScenes;
331 | Object.entries(this.scenes).forEach(([key, configScene]) => {
332 | const actions = toArray(configScene);
333 | this.logger.info(`[Scenes] Registering scene [${key}]`);
334 | for (const action of actions) {
335 | if (action.entity) {
336 | if (!this.zigbee.resolveEntity(action.entity)) {
337 | this.logger.error(`[Scenes] Config validation error for [${key}]: entity #${action.entity}# not found`);
338 | }
339 | if (!action.payload) {
340 | this.logger.error(`[Scenes] Config validation error for [${key}]: entity #${action.entity}# payload not found`);
341 | }
342 | } else if (action.scene) {
343 | this.logger.info(`[Scenes] - scene #${action.scene}#`);
344 | }
345 | }
346 | });
347 | } catch (_error) {
348 | this.logger.info(`[Automations] Error loading file scenes.yaml: see stderr for explanation`);
349 | }
350 |
351 | let configAutomations: ConfigAutomations = {};
352 | try {
353 | // configAutomations = (yaml.readIfExists(data.joinPath('automations.yaml')) || {}) as ConfigAutomations;
354 | configAutomations = (readIfExists(joinPath('automations.yaml')) || {}) as ConfigAutomations;
355 | } catch (error) {
356 | this.logger.error(`[Automations] Error loading file automations.yaml: see stderr for explanation`);
357 | console.log(error);
358 | return false;
359 | }
360 |
361 | Object.entries(configAutomations).forEach(([key, configAutomation]) => {
362 | const actions = toArray(configAutomation.action);
363 | const conditions = configAutomation.condition ? toArray(configAutomation.condition) : [];
364 | const triggers = toArray(configAutomation.trigger);
365 |
366 | // Check automation
367 | if (configAutomation.active === false) {
368 | this.logger.info(`[Automations] Automation [${key}] not registered since active is false`);
369 | return;
370 | }
371 | if (!configAutomation.trigger) {
372 | this.logger.error(`[Automations] Config validation error for [${key}]: no triggers defined`);
373 | return;
374 | }
375 | if (!configAutomation.action) {
376 | this.logger.error(`[Automations] Config validation error for [${key}]: no actions defined`);
377 | return;
378 | }
379 | // Check triggers
380 | for (const trigger of triggers) {
381 | if (!trigger.time && !trigger.entity) {
382 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity not defined`);
383 | return;
384 | }
385 | if (!trigger.time && !this.zigbee.resolveEntity(trigger.entity)) {
386 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${trigger.entity}# not found`);
387 | return;
388 | }
389 | }
390 | // Check actions
391 | for (const action of actions) {
392 | if (!action.entity && !action.scene) {
393 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity or action scene not defined`);
394 | return;
395 | }
396 | if (action.entity && !this.zigbee.resolveEntity(action.entity)) {
397 | this.logger.error(`[Automations] Config validation error for [${key}]: action entity #${action.entity}# not found`);
398 | return;
399 | }
400 | if (action.entity && !action.payload) {
401 | this.logger.error(`[Automations] Config validation error for [${key}]: action payload not defined`);
402 | return;
403 | }
404 | if (action.scene && !this.scenes[action.scene]) {
405 | this.logger.error(`[Automations] Config validation error for [${key}]: action scene #${action.scene}# not found`);
406 | return;
407 | }
408 | }
409 | // Check conditions
410 | for (const condition of conditions) {
411 | if (
412 | !(condition as ConfigEntityCondition).entity &&
413 | !(condition as ConfigTimeCondition).after &&
414 | !(condition as ConfigTimeCondition).before &&
415 | !(condition as ConfigTimeCondition).between &&
416 | !(condition as ConfigTimeCondition).weekday
417 | ) {
418 | this.logger.error(`[Automations] Config validation error for [${key}]: condition unknown`);
419 | return;
420 | }
421 | if ((condition as ConfigEntityCondition).entity && !this.zigbee.resolveEntity((condition as ConfigEntityCondition).entity)) {
422 | this.logger.error(`[Automations] Config validation error for [${key}]: condition entity #${(condition as ConfigEntityCondition).entity}# not found`);
423 | return;
424 | }
425 | }
426 |
427 | for (const trigger of triggers) {
428 | if (trigger.time !== undefined) {
429 | const timeTrigger = trigger as ConfigTimeTrigger;
430 | this.logger.info(`[Automations] Registering time automation [${key}] trigger: ${timeTrigger.time}`);
431 | const suncalcs = Object.values(ConfigSunCalc);
432 | if (suncalcs.includes(timeTrigger.time as ConfigSunCalc)) {
433 | if (!timeTrigger.latitude || !timeTrigger.longitude) {
434 | this.logger.error(`[Automations] Config validation error for [${key}]: latitude and longitude are mandatory for ${trigger.time}`);
435 | return;
436 | }
437 | const suncalc = new SunCalc();
438 | const times = suncalc.getTimes(new Date(), timeTrigger.latitude, timeTrigger.longitude, timeTrigger.elevation ? timeTrigger.elevation : 0) as object;
439 | this.logger.debug(
440 | `[Automations] Sunrise at ${times[ConfigSunCalc.SUNRISE].toLocaleTimeString()} sunset at ${times[ConfigSunCalc.SUNSET].toLocaleTimeString()} for latitude:${
441 | timeTrigger.latitude
442 | } longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0}`,
443 | );
444 | this.log.debug(
445 | `[Automations] For latitude:${timeTrigger.latitude} longitude:${timeTrigger.longitude} elevation:${timeTrigger.elevation ? timeTrigger.elevation : 0} suncalc are:\n`,
446 | times,
447 | );
448 | const options = {
449 | hour12: false,
450 | hour: '2-digit',
451 | minute: '2-digit',
452 | second: '2-digit',
453 | };
454 | const time = times[trigger.time].toLocaleTimeString('en-GB', options);
455 | this.log.debug(`[Automations] Registering time automation [${key}] trigger: ${time}`);
456 | if (!this.timeAutomations[time]) this.timeAutomations[time] = [];
457 | this.timeAutomations[time].push({
458 | name: key,
459 | execute_once: configAutomation.execute_once,
460 | trigger: timeTrigger,
461 | action: actions,
462 | condition: conditions,
463 | });
464 | } else if (this.matchTimeString(timeTrigger.time)) {
465 | if (!this.timeAutomations[timeTrigger.time]) this.timeAutomations[timeTrigger.time] = [];
466 | this.timeAutomations[timeTrigger.time].push({
467 | name: key,
468 | execute_once: configAutomation.execute_once,
469 | trigger: timeTrigger,
470 | action: actions,
471 | condition: conditions,
472 | });
473 | } else {
474 | this.logger.error(`[Automations] Config validation error for [${key}]: time syntax error for ${trigger.time}`);
475 | return;
476 | }
477 | }
478 | if (trigger.entity !== undefined) {
479 | const eventTrigger = trigger as ConfigEventTrigger;
480 | if (!this.zigbee.resolveEntity(eventTrigger.entity)) {
481 | this.logger.error(`[Automations] Config validation error for [${key}]: trigger entity #${eventTrigger.entity}# not found`);
482 | return;
483 | }
484 | this.logger.info(`[Automations] Registering event automation [${key}] trigger: entity #${eventTrigger.entity}#`);
485 | const entities = toArray(eventTrigger.entity);
486 | for (const entity of entities) {
487 | if (!this.eventAutomations[entity]) {
488 | this.eventAutomations[entity] = [];
489 | }
490 | this.eventAutomations[entity].push({
491 | name: key,
492 | execute_once: configAutomation.execute_once,
493 | trigger: eventTrigger,
494 | action: actions,
495 | condition: conditions,
496 | });
497 | }
498 | }
499 | } // for (const trigger of triggers)
500 | });
501 | return true;
502 | }
503 |
504 | /**
505 | * Check a time string and return a Date or undefined if error
506 | *
507 | * @param timeString
508 | */
509 | private matchTimeString(timeString: TimeStringType): Date | undefined {
510 | if (timeString.length !== 8) return undefined;
511 | const match = timeString.match(/(\d{2}):(\d{2}):(\d{2})/);
512 | if (match && parseInt(match[1], 10) <= 23 && parseInt(match[2], 10) <= 59 && parseInt(match[3], 10) <= 59) {
513 | const time = new Date();
514 | time.setHours(parseInt(match[1], 10));
515 | time.setMinutes(parseInt(match[2], 10));
516 | time.setSeconds(parseInt(match[3], 10));
517 | return time;
518 | }
519 | return undefined;
520 | }
521 |
522 | /**
523 | * Start a timeout in the first second of tomorrow date.
524 | * It also reload the suncalc times for the next day.
525 | * The timeout callback then will start the time triggers for tomorrow and start again a timeout for the next day.
526 | */
527 | private startMidnightTimeout(): void {
528 | const now = new Date();
529 | const timeEvent = new Date();
530 | timeEvent.setHours(23);
531 | timeEvent.setMinutes(59);
532 | timeEvent.setSeconds(59);
533 | this.logger.debug(`[Automations] Set timeout to reload for time automations`);
534 | this.midnightTimeout = setTimeout(
535 | () => {
536 | this.logger.info(`[Automations] Run timeout to reload time automations`);
537 |
538 | const newTimeAutomations = {};
539 | const suncalcs = Object.values(ConfigSunCalc);
540 | Object.keys(this.timeAutomations).forEach((key) => {
541 | const timeAutomationArray = this.timeAutomations[key];
542 | timeAutomationArray.forEach((timeAutomation) => {
543 | if (suncalcs.includes(timeAutomation.trigger.time as ConfigSunCalc)) {
544 | if (!timeAutomation.trigger.latitude || !timeAutomation.trigger.longitude) return;
545 | const suncalc = new SunCalc();
546 | const times = suncalc.getTimes(new Date(), timeAutomation.trigger.latitude, timeAutomation.trigger.longitude, timeAutomation.trigger.elevation ?? 0);
547 | // this.log.info(`Key:[${key}] For latitude:${timeAutomation.trigger.latitude} longitude:${timeAutomation.trigger.longitude} elevation:${timeAutomation.trigger.elevation ?? 0} suncalcs are:\n`, times);
548 | const options = {
549 | hour12: false,
550 | hour: '2-digit',
551 | minute: '2-digit',
552 | second: '2-digit',
553 | };
554 | const time = times[timeAutomation.trigger.time].toLocaleTimeString('en-GB', options);
555 | // this.log.info(`Registering suncalc time automation at time [${time}] for:`, timeAutomation);
556 | this.logger.info(`[Automations] Registering suncalc time automation at time [${time}] for: ${this.stringify(timeAutomation)}`);
557 | if (!newTimeAutomations[time]) newTimeAutomations[time] = [];
558 | newTimeAutomations[time].push(timeAutomation);
559 | this.startTimeTriggers(time, timeAutomation);
560 | } else {
561 | // this.log.info(`Registering normal time automation at time [${key}] for:`, timeAutomation);
562 | this.logger.info(`[Automations] Registering normal time automation at time [${key}] for: ${this.stringify(timeAutomation)}`);
563 | if (!newTimeAutomations[key]) newTimeAutomations[key] = [];
564 | newTimeAutomations[key].push(timeAutomation);
565 | this.startTimeTriggers(key, timeAutomation);
566 | }
567 | });
568 | });
569 | this.timeAutomations = newTimeAutomations;
570 |
571 | this.startMidnightTimeout();
572 | },
573 | timeEvent.getTime() - now.getTime() + 2000,
574 | );
575 | this.midnightTimeout.unref();
576 | }
577 |
578 | /**
579 | * Take the key of TimeAutomations that is a string like hh:mm:ss, convert it in a Date object of today
580 | * and set the timer if not already passed for today.
581 | * The timeout callback then will run the automations
582 | *
583 | * @param key
584 | * @param automation
585 | */
586 | private startTimeTriggers(key: TimeId, automation: TimeAutomation): void {
587 | const now = new Date();
588 |
589 | const timeEvent = this.matchTimeString(key);
590 | if (timeEvent !== undefined) {
591 | if (timeEvent.getTime() > now.getTime()) {
592 | this.logger.debug(`[Automations] Set timeout at ${timeEvent.toLocaleString()} for [${automation.name}]`);
593 | const timeout = setTimeout(() => {
594 | delete this.triggerForTimeouts[automation.name];
595 | this.logger.debug(`[Automations] Timeout for [${automation.name}]`);
596 | this.runActionsWithConditions(automation, automation.condition, automation.action);
597 | }, timeEvent.getTime() - now.getTime());
598 | timeout.unref();
599 | this.triggerForTimeouts[automation.name] = timeout;
600 | } else {
601 | this.logger.debug(`[Automations] Timeout at ${timeEvent.toLocaleString()} is passed for [${automation.name}]`);
602 | }
603 | } else {
604 | this.logger.error(`[Automations] Timeout config error at ${key} for [${automation.name}]`);
605 | }
606 | }
607 |
608 | /**
609 | * null - return
610 | * false - return and stop timer
611 | * true - start the automation
612 | *
613 | * @param automation
614 | * @param configTrigger
615 | * @param update
616 | * @param from
617 | * @param to
618 | */
619 | private checkTrigger(automation: EventAutomation, configTrigger: ConfigEventTrigger, update: StateChangeUpdate, from: StateChangeFrom, to: StateChangeTo): boolean | null {
620 | let trigger;
621 | let attribute;
622 | let result;
623 | let actions;
624 |
625 | // this.log.warning(`[Automations] Trigger check [${automation.name}] update: ${this.stringify(update)} from: ${this.stringify(from)} to: ${this.stringify(to)}`);
626 |
627 | if (configTrigger.action !== undefined) {
628 | if (!Object.prototype.hasOwnProperty.call(update, 'action')) {
629 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no 'action' in update for #${configTrigger.entity}#`);
630 | return null;
631 | }
632 | trigger = configTrigger as ConfigActionTrigger;
633 | actions = toArray(trigger.action);
634 | result = actions.includes(update.action as ConfigActionType);
635 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger is ${result} for #${configTrigger.entity}# action(s): ${this.stringify(actions)}`);
636 | return result;
637 | } else if (configTrigger.attribute !== undefined) {
638 | trigger = configTrigger as ConfigAttributeTrigger;
639 | attribute = trigger.attribute;
640 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) {
641 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`);
642 | return null;
643 | }
644 | if (from[attribute] === to[attribute]) {
645 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`);
646 | return null;
647 | }
648 |
649 | if (typeof trigger.equal !== 'undefined' || typeof trigger.state !== 'undefined') {
650 | const value = trigger.state !== undefined ? trigger.state : trigger.equal;
651 | if (to[attribute] !== value) {
652 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${value} for #${configTrigger.entity}#`);
653 | return false;
654 | }
655 | if (from[attribute] === value) {
656 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already = ${value} for #${configTrigger.entity}#`);
657 | return null;
658 | }
659 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger equal/state ${value} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `);
660 | }
661 |
662 | if (typeof trigger.not_equal !== 'undefined') {
663 | if (to[attribute] === trigger.not_equal) {
664 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' = ${trigger.not_equal} for #${configTrigger.entity}#`);
665 | return false;
666 | }
667 | if (from[attribute] !== trigger.not_equal) {
668 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already != ${trigger.not_equal} for #${configTrigger.entity}#`);
669 | return null;
670 | }
671 | this.logger.debug(
672 | `[Automations] Trigger check [${automation.name}] trigger not equal ${trigger.not_equal} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `,
673 | );
674 | }
675 |
676 | if (typeof trigger.above !== 'undefined') {
677 | if (to[attribute] <= trigger.above) {
678 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' <= ${trigger.above} for #${configTrigger.entity}#`);
679 | return false;
680 | }
681 | if (from[attribute] > trigger.above) {
682 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already > ${trigger.above} for #${configTrigger.entity}#`);
683 | return null;
684 | }
685 | this.logger.debug(
686 | `[Automations] Trigger check [${automation.name}] trigger above ${trigger.above} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `,
687 | );
688 | }
689 |
690 | if (typeof trigger.below !== 'undefined') {
691 | if (to[attribute] >= trigger.below) {
692 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' >= ${trigger.below} for #${configTrigger.entity}#`);
693 | return false;
694 | }
695 | if (from[attribute] < trigger.below) {
696 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' already < ${trigger.below} for #${configTrigger.entity}#`);
697 | return null;
698 | }
699 | this.logger.debug(
700 | `[Automations] Trigger check [${automation.name}] trigger below ${trigger.below} is true for #${configTrigger.entity}# ${attribute} is ${to[attribute]} `,
701 | );
702 | }
703 | return true;
704 | } else if (configTrigger.state !== undefined) {
705 | trigger = configTrigger as ConfigStateTrigger;
706 | attribute = 'state';
707 | if (!Object.prototype.hasOwnProperty.call(update, attribute) || !Object.prototype.hasOwnProperty.call(to, attribute)) {
708 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' published for #${configTrigger.entity}#`);
709 | return null;
710 | }
711 | if (from[attribute] === to[attribute]) {
712 | this.logger.debug(`[Automations] Trigger check [${automation.name}] no '${attribute}' change for #${configTrigger.entity}#`);
713 | return null;
714 | }
715 | if (to[attribute] !== trigger.state) {
716 | this.logger.debug(`[Automations] Trigger check [${automation.name}] '${attribute}' != ${trigger.state} for #${configTrigger.entity}#`);
717 | return null;
718 | }
719 | this.logger.debug(`[Automations] Trigger check [${automation.name}] trigger state ${trigger.state} is true for #${configTrigger.entity}# state is ${to[attribute]}`);
720 | return true;
721 | }
722 | return false;
723 | }
724 |
725 | private checkCondition(automation: EventAutomation, condition: ConfigCondition): boolean {
726 | let timeResult = true;
727 | let eventResult = true;
728 |
729 | if (
730 | (condition as ConfigTimeCondition).after ||
731 | (condition as ConfigTimeCondition).before ||
732 | (condition as ConfigTimeCondition).between ||
733 | (condition as ConfigTimeCondition).weekday
734 | ) {
735 | timeResult = this.checkTimeCondition(automation, condition as ConfigTimeCondition);
736 | }
737 | if ((condition as ConfigEntityCondition).entity) {
738 | eventResult = this.checkEntityCondition(automation, condition as ConfigEntityCondition);
739 | }
740 | return timeResult && eventResult;
741 | }
742 |
743 | // Return false if condition is false
744 | private checkTimeCondition(automation: EventAutomation, condition: ConfigTimeCondition): boolean {
745 | // this.logger.info(`[Automations] checkTimeCondition [${automation.name}]: ${this.stringify(condition)}`);
746 | const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
747 | const now = new Date();
748 | if (condition.weekday && !condition.weekday.includes(days[now.getDay()])) {
749 | this.logger.debug(
750 | `[Automations] Condition check [${automation.name}] time condition is false for weekday: ${this.stringify(condition.weekday)} since today is ${days[now.getDay()]}`,
751 | );
752 | return false;
753 | }
754 | if (condition.before) {
755 | const time = this.matchTimeString(condition.before);
756 | if (time !== undefined) {
757 | if (now.getTime() > time.getTime()) {
758 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for before: ${condition.before} since now is ${now.toLocaleTimeString()}`);
759 | return false;
760 | }
761 | } else {
762 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: before #${condition.before}# ignoring condition`);
763 | }
764 | }
765 | if (condition.after) {
766 | const time = this.matchTimeString(condition.after);
767 | if (time !== undefined) {
768 | if (now.getTime() < time.getTime()) {
769 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is false for after: ${condition.after} since now is ${now.toLocaleTimeString()}`);
770 | return false;
771 | }
772 | } else {
773 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: after #${condition.after}# ignoring condition`);
774 | }
775 | }
776 | if (condition.between) {
777 | const [startTimeStr, endTimeStr] = condition.between.split('-');
778 | const startTime = this.matchTimeString(startTimeStr);
779 | const endTime = this.matchTimeString(endTimeStr);
780 | if (startTime !== undefined && endTime !== undefined) {
781 | // Internal time span: between: 08:00:00-20:00:00
782 | if (startTime.getTime() < endTime.getTime()) {
783 | if (now.getTime() < startTime.getTime() || now.getTime() > endTime.getTime()) {
784 | this.logger.debug(
785 | `[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`,
786 | );
787 | return false;
788 | }
789 | }
790 | // External time span: between: 20:00:00-06:00:00
791 | else if (startTime.getTime() > endTime.getTime()) {
792 | if (now.getTime() < startTime.getTime() && now.getTime() > endTime.getTime()) {
793 | this.logger.debug(
794 | `[Automations] Condition check [${automation.name}] time condition is false for between: ${condition.between} since now is ${now.toLocaleTimeString()}`,
795 | );
796 | return false;
797 | }
798 | }
799 | } else {
800 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: between #${condition.between}# ignoring condition`);
801 | }
802 | }
803 | this.logger.debug(`[Automations] Condition check [${automation.name}] time condition is true for ${this.stringify(condition)}`);
804 | return true;
805 | }
806 |
807 | // Return false if condition is false
808 | private checkEntityCondition(automation: EventAutomation, condition: ConfigEntityCondition): boolean {
809 | if (!condition.entity) {
810 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: condition entity not specified`);
811 | return false;
812 | }
813 | const entity = this.zigbee.resolveEntity(condition.entity);
814 | if (!entity) {
815 | this.logger.error(`[Automations] Condition check [${automation.name}] config validation error: entity #${condition.entity}# not found`);
816 | return false;
817 | }
818 | const attribute = condition.attribute || 'state';
819 | const value = this.state.get(entity)[attribute];
820 | if (condition.state !== undefined && value !== condition.state) {
821 | this.logger.debug(
822 | `[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not '${condition.state}'`,
823 | );
824 | return false;
825 | }
826 | if (condition.attribute !== undefined && condition.equal !== undefined && value !== condition.equal) {
827 | this.logger.debug(
828 | `[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not equal '${condition.equal}'`,
829 | );
830 | return false;
831 | }
832 | if (condition.attribute !== undefined && condition.below !== undefined && value >= condition.below) {
833 | this.logger.debug(
834 | `[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not below '${condition.below}'`,
835 | );
836 | return false;
837 | }
838 | if (condition.attribute !== undefined && condition.above !== undefined && value <= condition.above) {
839 | this.logger.debug(
840 | `[Automations] Condition check [${automation.name}] event condition is false for entity #${condition.entity}# attribute '${attribute}' is '${value}' not above '${condition.above}'`,
841 | );
842 | return false;
843 | }
844 | this.logger.debug(`[Automations] Condition check [${automation.name}] event condition is true for entity #${condition.entity}# attribute '${attribute}' is '${value}'`);
845 | return true;
846 | }
847 |
848 | private runActions(automation: EventAutomation, actions: ConfigAction[]): void {
849 | for (const action of actions) {
850 | // Check if action is scene and run it
851 | if (action.scene && typeof action.scene === 'string') {
852 | this.log.warning(`Executing scene: ${action.scene}`);
853 | this.runActions({ name: action.scene } as EventAutomation, this.scenes[action.scene] as ConfigAction[]);
854 | continue;
855 | }
856 |
857 | // Check if action is entity and run it
858 | const entity = this.zigbee.resolveEntity(action.entity);
859 | if (!entity) {
860 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`);
861 | continue;
862 | }
863 | let data: ConfigActionPayload;
864 | // this.log.warn('Payload:', typeof action.payload, action.payload)
865 | if (typeof action.payload === 'string') {
866 | if (action.payload === ConfigPayload.TURN_ON) {
867 | data = { state: ConfigState.ON };
868 | } else if (action.payload === ConfigPayload.TURN_OFF) {
869 | data = { state: ConfigState.OFF };
870 | } else if (action.payload === ConfigPayload.TOGGLE) {
871 | data = { state: ConfigState.TOGGLE };
872 | } else {
873 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`);
874 | return;
875 | }
876 | } else if (typeof action.payload === 'object') {
877 | data = action.payload;
878 | } else {
879 | this.logger.error(`[Automations] Run automation [${automation.name}] for entity #${action.entity}# error: payload can be turn_on turn_off toggle or an object`);
880 | return;
881 | }
882 | if (action.logger === 'info') this.logger.info(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
883 | else if (action.logger === 'warning')
884 | this.logger.warning(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
885 | else if (action.logger === 'error') this.logger.error(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
886 | else this.logger.debug(`[Automations] Run automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}#`);
887 | // this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, Buffer.from(this.payloadStringify(data)));
888 | // This is even faster cause we skip one passage.
889 | this.eventBus.emitMQTTMessage({
890 | topic: `${this.mqttBaseTopic}/${entity.name}/set`,
891 | message: this.payloadStringify(data),
892 | });
893 | if (action.turn_off_after) {
894 | this.startActionTurnOffTimeout(automation, action);
895 | }
896 | } // End for (const action of actions)
897 | if (automation.execute_once === true) {
898 | this.removeAutomation(automation.name);
899 | }
900 | }
901 |
902 | // Remove automation that has execute_once: true
903 | private removeAutomation(name: string): void {
904 | this.logger.debug(`[Automations] Uregistering automation [${name}]`);
905 | // this.log.warning(`Uregistering automation [${name}]`);
906 | Object.keys(this.eventAutomations).forEach((entity) => {
907 | // this.log.warning(`Entity: #${entity}#`);
908 | Object.values(this.eventAutomations[entity]).forEach((eventAutomation, index) => {
909 | if (eventAutomation.name === name) {
910 | // this.log.warning(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`);
911 | this.eventAutomations[entity].splice(index, 1);
912 | } else {
913 | // this.log.info(`Entity: #${entity}# ${index} event automation: ${eventAutomation.name}`);
914 | }
915 | });
916 | });
917 | Object.keys(this.timeAutomations).forEach((now) => {
918 | // this.log.warning(`Time: #${now}#`);
919 | Object.values(this.timeAutomations[now]).forEach((timeAutomation, index) => {
920 | if (timeAutomation.name === name) {
921 | // this.log.warning(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`);
922 | this.timeAutomations[now].splice(index, 1);
923 | } else {
924 | // this.log.info(`Time: #${now}# ${index} time automation: ${timeAutomation.name}`);
925 | }
926 | });
927 | });
928 | }
929 |
930 | // Stop the turn_off_after timeout
931 | private stopActionTurnOffTimeout(automation: EventAutomation, action: ConfigAction): void {
932 | const timeout = this.turnOffAfterTimeouts[automation.name + action.entity];
933 | if (timeout) {
934 | this.logger.debug(`[Automations] Stop turn_off_after timeout for automation [${automation.name}]`);
935 | clearTimeout(timeout);
936 | delete this.turnOffAfterTimeouts[automation.name + action.entity];
937 | }
938 | }
939 |
940 | // Start the turn_off_after timeout
941 | private startActionTurnOffTimeout(automation: EventAutomation, action: ConfigAction): void {
942 | this.stopActionTurnOffTimeout(automation, action);
943 | this.logger.debug(`[Automations] Start ${action.turn_off_after} seconds turn_off_after timeout for automation [${automation.name}]`);
944 | const timeout = setTimeout(() => {
945 | delete this.turnOffAfterTimeouts[automation.name + action.entity];
946 | // this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}]`);
947 | const entity = this.zigbee.resolveEntity(action.entity);
948 | if (!entity) {
949 | this.logger.error(`[Automations] Entity #${action.entity}# not found so ignoring this action`);
950 | this.stopActionTurnOffTimeout(automation, action);
951 | return;
952 | }
953 | const data = action.payload_off ?? { state: ConfigState.OFF };
954 | if (action.logger === 'info')
955 | this.logger.info(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
956 | else if (action.logger === 'warning')
957 | this.logger.warning(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
958 | else if (action.logger === 'error')
959 | this.logger.error(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
960 | else this.logger.debug(`[Automations] Turn_off_after timeout for automation [${automation.name}] send ${this.payloadStringify(data)} to entity #${action.entity}# `);
961 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${entity.name}/set`, Buffer.from(this.payloadStringify(data)));
962 | }, action.turn_off_after! * 1000);
963 | timeout.unref();
964 | this.turnOffAfterTimeouts[automation.name + action.entity] = timeout;
965 | }
966 |
967 | private runActionsWithConditions(automation: EventAutomation, conditions: ConfigCondition[], actions: ConfigAction[]): void {
968 | for (const condition of conditions) {
969 | // this.log.warning(`runActionsWithConditions: conditon: ${this.stringify(condition)}`);
970 | if (!this.checkCondition(automation, condition)) {
971 | return;
972 | }
973 | }
974 | this.runActions(automation, actions);
975 | }
976 |
977 | // Stop the trigger_for timeout
978 | private stopTriggerForTimeout(automation: EventAutomation): void {
979 | const timeout = this.triggerForTimeouts[automation.name];
980 | if (timeout) {
981 | // this.log.debug(`Stop timeout for automation [${automation.name}] trigger: ${this.stringify(automation.trigger)}`);
982 | this.logger.debug(`[Automations] Stop trigger-for timeout for automation [${automation.name}]`);
983 | clearTimeout(timeout);
984 | delete this.triggerForTimeouts[automation.name];
985 | }
986 | }
987 |
988 | // Start the trigger_for timeout
989 | private startTriggerForTimeout(automation: EventAutomation): void {
990 | if (automation.trigger.for === undefined || automation.trigger.for === 0) {
991 | this.logger.error(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout error for automation [${automation.name}]`);
992 | return;
993 | }
994 | this.logger.debug(`[Automations] Start ${automation.trigger.for} seconds trigger-for timeout for automation [${automation.name}]`);
995 | const timeout = setTimeout(() => {
996 | delete this.triggerForTimeouts[automation.name];
997 | this.logger.debug(`[Automations] Trigger-for timeout for automation [${automation.name}]`);
998 | this.runActionsWithConditions(automation, automation.condition, automation.action);
999 | }, automation.trigger.for * 1000);
1000 | timeout.unref();
1001 | this.triggerForTimeouts[automation.name] = timeout;
1002 | }
1003 |
1004 | private runAutomationIfMatches(automation: EventAutomation, update: StateChangeUpdate, from: StateChangeFrom, to: StateChangeTo): void {
1005 | const triggerResult = this.checkTrigger(automation, automation.trigger, update, from, to);
1006 | if (triggerResult === false) {
1007 | this.stopTriggerForTimeout(automation);
1008 | return;
1009 | }
1010 | if (triggerResult === null) {
1011 | return;
1012 | }
1013 | const timeout = this.triggerForTimeouts[automation.name];
1014 | if (timeout) {
1015 | this.logger.debug(`[Automations] Waiting trigger-for timeout for automation [${automation.name}]`);
1016 | return;
1017 | } else {
1018 | this.logger.debug(`[Automations] Start automation [${automation.name}]`);
1019 | }
1020 | if (automation.trigger.for) {
1021 | this.startTriggerForTimeout(automation);
1022 | return;
1023 | }
1024 | this.runActionsWithConditions(automation, automation.condition, automation.action);
1025 | }
1026 |
1027 | private findAndRun(entityId: EntityId, update: StateChangeUpdate, from: StateChangeFrom, to: StateChangeTo): void {
1028 | const automations = this.eventAutomations[entityId];
1029 | if (!automations) {
1030 | return;
1031 | }
1032 | for (const automation of automations) {
1033 | this.runAutomationIfMatches(automation, update, from, to);
1034 | }
1035 | }
1036 |
1037 | // Process MQTT messages private message for automations.
1038 | // Publish: topic "zigbee2mqtt-automations/" with raw message "execute"
1039 | // Publish: topic "zigbee2mqtt-scenes/" with raw message "execute"
1040 | private processMessage(message: MQTTMessage) {
1041 | const automationsMatch = message.topic.match(this.automationsTopicRegex);
1042 | if (automationsMatch) {
1043 | for (const automations of Object.values(this.eventAutomations)) {
1044 | for (const automation of automations) {
1045 | if (automation.name == automationsMatch[1]) {
1046 | this.logger.info(`[Automations] MQTT message for [${automationsMatch[1]}]: "${message.message}"`);
1047 |
1048 | switch (message.message.trim()) {
1049 | case MessagePayload.EXECUTE:
1050 | this.log.warning(`Executing automation "${automation.name}": ${this.stringify(automation.action, true)}`);
1051 | this.runActions(automation, automation.action);
1052 | break;
1053 | }
1054 |
1055 | return;
1056 | }
1057 | }
1058 | }
1059 | }
1060 |
1061 | const scenesMatch = message.topic.match(this.scenesTopicRegex);
1062 | if (scenesMatch) {
1063 | Object.entries(this.scenes).forEach(([key]) => {
1064 | if (key == scenesMatch[1]) {
1065 | this.logger.info(`[Scenes] MQTT message for [${scenesMatch[1]}]: "${message.message}"`);
1066 |
1067 | switch (message.message.trim()) {
1068 | case MessagePayload.EXECUTE:
1069 | this.log.warning(`Executing scene "${key}": ${this.stringify(this.scenes[key], true)}`);
1070 | this.runActions({ name: key } as EventAutomation, this.scenes[key] as ConfigAction[]);
1071 | break;
1072 | }
1073 | }
1074 | });
1075 | }
1076 | }
1077 |
1078 | async start() {
1079 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
1080 | this.eventBus.onStateChange(this, (data: any) => {
1081 | this.findAndRun(data.entity.name, data.update, data.from, data.to);
1082 | });
1083 |
1084 | this.mqtt.subscribe(`${this.automationsTopic}/+`);
1085 | this.mqtt.subscribe(`${this.scenesTopic}/+`);
1086 |
1087 | this.eventBus.onMQTTMessage(this, (data: MQTTMessage) => {
1088 | this.processMessage(data);
1089 | });
1090 | }
1091 |
1092 | async stop() {
1093 | this.logger.debug(`[Automations] Extension unloading`);
1094 | for (const key of Object.keys(this.triggerForTimeouts)) {
1095 | this.logger.debug(`[Automations] Clearing timeout ${key}`);
1096 | clearTimeout(this.triggerForTimeouts[key]);
1097 | delete this.triggerForTimeouts[key];
1098 | }
1099 | for (const key of Object.keys(this.turnOffAfterTimeouts)) {
1100 | this.logger.debug(`[Automations] Clearing timeout ${key}`);
1101 | clearTimeout(this.turnOffAfterTimeouts[key]);
1102 | delete this.turnOffAfterTimeouts[key];
1103 | }
1104 | clearTimeout(this.midnightTimeout);
1105 |
1106 | this.logger.debug(`[Automations] Removing listeners`);
1107 | this.eventBus.removeListeners(this);
1108 | this.logger.debug(`[Automations] Extension unloaded`);
1109 | }
1110 |
1111 | private payloadStringify(payload: object): string {
1112 | return this.stringify(payload, false, 255, 255, 35, 220, 159, 1, '"', '"');
1113 | }
1114 |
1115 | private stringify(
1116 | payload: object,
1117 | enableColors = false,
1118 | colorPayload = 255,
1119 | colorKey = 255,
1120 | colorString = 35,
1121 | colorNumber = 220,
1122 | colorBoolean = 159,
1123 | colorUndefined = 1,
1124 | keyQuote = '',
1125 | stringQuote = "'",
1126 | ): string {
1127 | const clr = (color: number) => {
1128 | return enableColors ? `\x1b[38;5;${color}m` : '';
1129 | };
1130 | const reset = () => {
1131 | return enableColors ? `\x1b[0m` : '';
1132 | };
1133 | const isArray = Array.isArray(payload);
1134 | let string = `${reset()}${clr(colorPayload)}` + (isArray ? '[ ' : '{ ');
1135 | Object.entries(payload).forEach(([key, value], index) => {
1136 | if (index > 0) {
1137 | string += ', ';
1138 | }
1139 | let newValue = '';
1140 | newValue = value;
1141 | if (typeof newValue === 'string') {
1142 | newValue = `${clr(colorString)}${stringQuote}${newValue}${stringQuote}${reset()}`;
1143 | }
1144 | if (typeof newValue === 'number') {
1145 | newValue = `${clr(colorNumber)}${newValue}${reset()}`;
1146 | }
1147 | if (typeof newValue === 'boolean') {
1148 | newValue = `${clr(colorBoolean)}${newValue}${reset()}`;
1149 | }
1150 | if (typeof newValue === 'undefined') {
1151 | newValue = `${clr(colorUndefined)}undefined${reset()}`;
1152 | }
1153 | if (typeof newValue === 'object') {
1154 | newValue = this.stringify(newValue, enableColors, colorPayload, colorKey, colorString, colorNumber, colorBoolean, colorUndefined, keyQuote, stringQuote);
1155 | }
1156 | // new
1157 | if (isArray) string += `${newValue}`;
1158 | else string += `${clr(colorKey)}${keyQuote}${key}${keyQuote}${reset()}: ${newValue}`;
1159 | });
1160 | return (string += ` ${clr(colorPayload)}` + (isArray ? ']' : '}') + `${reset()}`);
1161 | }
1162 | }
1163 |
1164 | /*
1165 | FROM HERE IS THE COPY IN TS OF SUNCALC PACKAGE https://www.npmjs.com/package/suncalc
1166 | */
1167 |
1168 | //
1169 | // Use https://www.latlong.net/ to get latidute and longitude based on your adress
1170 | //
1171 |
1172 | // sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas
1173 |
1174 | // shortcuts for easier to read formulas
1175 | const PI = Math.PI,
1176 | sin = Math.sin,
1177 | cos = Math.cos,
1178 | tan = Math.tan,
1179 | asin = Math.asin,
1180 | atan = Math.atan2,
1181 | acos = Math.acos,
1182 | rad = PI / 180;
1183 |
1184 | // date/time constants and conversions
1185 | const dayMs = 1000 * 60 * 60 * 24,
1186 | J1970 = 2440588,
1187 | J2000 = 2451545;
1188 | function toJulian(date) {
1189 | return date.valueOf() / dayMs - 0.5 + J1970;
1190 | }
1191 | function fromJulian(j) {
1192 | return new Date((j + 0.5 - J1970) * dayMs);
1193 | }
1194 | function toDays(date) {
1195 | return toJulian(date) - J2000;
1196 | }
1197 |
1198 | // general calculations for position
1199 | const e = rad * 23.4397; // obliquity of the Earth
1200 | function rightAscension(l, b) {
1201 | return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l));
1202 | }
1203 | function declination(l, b) {
1204 | return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l));
1205 | }
1206 | function azimuth(H, phi, dec) {
1207 | return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi));
1208 | }
1209 | function altitude(H, phi, dec) {
1210 | return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H));
1211 | }
1212 | function siderealTime(d, lw) {
1213 | return rad * (280.16 + 360.9856235 * d) - lw;
1214 | }
1215 | function astroRefraction(h) {
1216 | if (h < 0)
1217 | // the following formula works for positive altitudes only.
1218 | h = 0; // if h = -0.08901179 a div/0 would occur.
1219 | // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
1220 | // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad:
1221 | return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179));
1222 | }
1223 |
1224 | // general sun calculations
1225 | function solarMeanAnomaly(d) {
1226 | return rad * (357.5291 + 0.98560028 * d);
1227 | }
1228 | function eclipticLongitude(M) {
1229 | const C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center
1230 | P = rad * 102.9372; // perihelion of the Earth
1231 | return M + C + P + PI;
1232 | }
1233 |
1234 | function sunCoords(d) {
1235 | const M = solarMeanAnomaly(d),
1236 | L = eclipticLongitude(M);
1237 | return {
1238 | dec: declination(L, 0),
1239 | ra: rightAscension(L, 0),
1240 | };
1241 | }
1242 |
1243 | // calculations for sun times
1244 | const J0 = 0.0009;
1245 | function julianCycle(d, lw) {
1246 | return Math.round(d - J0 - lw / (2 * PI));
1247 | }
1248 | function approxTransit(Ht, lw, n) {
1249 | return J0 + (Ht + lw) / (2 * PI) + n;
1250 | }
1251 | function solarTransitJ(ds, M, L) {
1252 | return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L);
1253 | }
1254 | function hourAngle(h, phi, d) {
1255 | return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d)));
1256 | }
1257 | function observerAngle(height) {
1258 | return (-2.076 * Math.sqrt(height)) / 60;
1259 | }
1260 | // returns set time for the given sun altitude
1261 | function getSetJ(h, lw, phi, dec, n, M, L) {
1262 | const w = hourAngle(h, phi, dec),
1263 | a = approxTransit(w, lw, n);
1264 | return solarTransitJ(a, M, L);
1265 | }
1266 |
1267 | // moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas
1268 | function moonCoords(d) {
1269 | // geocentric ecliptic coordinates of the moon
1270 | const L = rad * (218.316 + 13.176396 * d), // ecliptic longitude
1271 | M = rad * (134.963 + 13.064993 * d), // mean anomaly
1272 | F = rad * (93.272 + 13.22935 * d), // mean distance
1273 | l = L + rad * 6.289 * sin(M), // longitude
1274 | b = rad * 5.128 * sin(F), // latitude
1275 | dt = 385001 - 20905 * cos(M); // distance to the moon in km
1276 |
1277 | return {
1278 | ra: rightAscension(l, b),
1279 | dec: declination(l, b),
1280 | dist: dt,
1281 | };
1282 | }
1283 |
1284 | function hoursLater(date, h) {
1285 | return new Date(date.valueOf() + (h * dayMs) / 24);
1286 | }
1287 |
1288 | class SunCalc {
1289 | // calculates sun position for a given date and latitude/longitude
1290 | // @ts-ignore: Unused method
1291 | private getPosition(date, lat, lng) {
1292 | const lw = rad * -lng,
1293 | phi = rad * lat,
1294 | d = toDays(date),
1295 | c = sunCoords(d),
1296 | H = siderealTime(d, lw) - c.ra;
1297 |
1298 | return {
1299 | azimuth: azimuth(H, phi, c.dec),
1300 | altitude: altitude(H, phi, c.dec),
1301 | };
1302 | }
1303 |
1304 | // sun times configuration (angle, morning name, evening name)
1305 | private times = [
1306 | [-0.833, 'sunrise', 'sunset'],
1307 | [-0.3, 'sunriseEnd', 'sunsetStart'],
1308 | [-6, 'dawn', 'dusk'],
1309 | [-12, 'nauticalDawn', 'nauticalDusk'],
1310 | [-18, 'nightEnd', 'night'],
1311 | [6, 'goldenHourEnd', 'goldenHour'],
1312 | ];
1313 |
1314 | // adds a custom time to the times config
1315 | // @ts-ignore: Unused method
1316 | private addTime(angle, riseName, setName) {
1317 | this.times.push([angle, riseName, setName]);
1318 | }
1319 |
1320 | // calculates sun times for a given date, latitude/longitude, and, optionally,
1321 | // the observer height (in meters) relative to the horizon
1322 | public getTimes(date, lat, lng, height) {
1323 | height = height || 0;
1324 |
1325 | const lw = rad * -lng,
1326 | phi = rad * lat,
1327 | dh = observerAngle(height),
1328 | d = toDays(date),
1329 | n = julianCycle(d, lw),
1330 | ds = approxTransit(0, lw, n),
1331 | M = solarMeanAnomaly(ds),
1332 | L = eclipticLongitude(M),
1333 | dec = declination(L, 0),
1334 | Jnoon = solarTransitJ(ds, M, L);
1335 | let i, len, time, h0, Jset, Jrise;
1336 | const result = {
1337 | solarNoon: fromJulian(Jnoon),
1338 | nadir: fromJulian(Jnoon - 0.5),
1339 | };
1340 |
1341 | for (i = 0, len = this.times.length; i < len; i += 1) {
1342 | time = this.times[i];
1343 | h0 = (time[0] + dh) * rad;
1344 | Jset = getSetJ(h0, lw, phi, dec, n, M, L);
1345 | Jrise = Jnoon - (Jset - Jnoon);
1346 | result[time[1]] = fromJulian(Jrise);
1347 | result[time[2]] = fromJulian(Jset);
1348 | }
1349 |
1350 | return result;
1351 | }
1352 |
1353 | private getMoonPosition(date, lat, lng) {
1354 | const lw = rad * -lng,
1355 | phi = rad * lat,
1356 | d = toDays(date),
1357 | c = moonCoords(d),
1358 | H = siderealTime(d, lw) - c.ra;
1359 | let h = altitude(H, phi, c.dec);
1360 | // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
1361 | const pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H));
1362 | h = h + astroRefraction(h); // altitude correction for refraction
1363 |
1364 | return {
1365 | azimuth: azimuth(H, phi, c.dec),
1366 | altitude: h,
1367 | distance: c.dist,
1368 | parallacticAngle: pa,
1369 | };
1370 | }
1371 |
1372 | // calculations for illumination parameters of the moon,
1373 | // based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and
1374 | // Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
1375 | // @ts-ignore: Unused method
1376 | private getMoonIllumination(date) {
1377 | const d = toDays(date || new Date()),
1378 | s = sunCoords(d),
1379 | m = moonCoords(d),
1380 | sdist = 149598000, // distance from Earth to Sun in km
1381 | phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)),
1382 | inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)),
1383 | angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) - cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra));
1384 |
1385 | return {
1386 | fraction: (1 + cos(inc)) / 2,
1387 | phase: 0.5 + (0.5 * inc * (angle < 0 ? -1 : 1)) / Math.PI,
1388 | angle: angle,
1389 | };
1390 | }
1391 |
1392 | // calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
1393 | // @ts-ignore: Unused method
1394 | private getMoonTimes(date, lat, lng, inUTC) {
1395 | const t = new Date(date);
1396 | if (inUTC) t.setUTCHours(0, 0, 0, 0);
1397 | else t.setHours(0, 0, 0, 0);
1398 | const hc = 0.133 * rad;
1399 | let h0 = this.getMoonPosition(t, lat, lng).altitude - hc;
1400 | let h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx;
1401 | // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
1402 | for (let i = 1; i <= 24; i += 2) {
1403 | h1 = this.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
1404 | h2 = this.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
1405 | a = (h0 + h2) / 2 - h1;
1406 | b = (h2 - h0) / 2;
1407 | xe = -b / (2 * a);
1408 | ye = (a * xe + b) * xe + h1;
1409 | d = b * b - 4 * a * h1;
1410 | roots = 0;
1411 |
1412 | if (d >= 0) {
1413 | dx = Math.sqrt(d) / (Math.abs(a) * 2);
1414 | x1 = xe - dx;
1415 | x2 = xe + dx;
1416 | if (Math.abs(x1) <= 1) roots++;
1417 | if (Math.abs(x2) <= 1) roots++;
1418 | if (x1 < -1) x1 = x2;
1419 | }
1420 |
1421 | if (roots === 1) {
1422 | if (h0 < 0) rise = i + x1;
1423 | else set = i + x1;
1424 | } else if (roots === 2) {
1425 | rise = i + (ye < 0 ? x2 : x1);
1426 | set = i + (ye < 0 ? x1 : x2);
1427 | }
1428 |
1429 | if (rise && set) break;
1430 |
1431 | h0 = h2;
1432 | }
1433 |
1434 | const result = {};
1435 |
1436 | if (rise) result[rise] = hoursLater(t, rise);
1437 | if (set) result[set] = hoursLater(t, set);
1438 |
1439 | if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
1440 |
1441 | return result;
1442 | }
1443 | }
1444 |
1445 | export = AutomationsExtension;
1446 |
--------------------------------------------------------------------------------