├── .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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 45 | 48 | 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Matterbridge Logo   zigbee2mqtt-automations changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | If you like this project and find it useful, please consider giving it a star on GitHub at https://github.com/Luligu/matterbridge-zigbee2mqtt and sponsoring it. 6 | 7 | 8 | Buy me a coffee 9 | 10 | 11 | ## [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 | Buy me a coffee 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 | Buy me a coffee 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 | Buy me a coffee 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 | Buy me a coffee 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 | Buy me a coffee 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 | Buy me a coffee 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 | Buy me a coffee 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 | Buy me a coffee 107 | 108 | 109 | ## [1.0.9] - 2024-11-29 110 | 111 | ### Fixed 112 | 113 | - [suncalc automations]: Fix conversion in toLocaleTimeString(). 114 | 115 | 116 | Buy me a coffee 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 | # Matterbridge Logo    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 | Buy me a coffee 28 | 29 | 30 | Check also the https://github.com/Luligu/matterbridge-zigbee2mqtt matterbridge zigbee2mqtt plugin. 31 | 32 | 33 | Matterbridge Zigbee2MQTT Plugin 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 | Buy me a coffee 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /dist/automations.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /** 3 | * @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 | --------------------------------------------------------------------------------