├── .gitignore ├── .github └── workflows │ └── node.js.yml ├── package.json ├── LICENSE ├── tsconfig.json ├── README.md ├── dist └── automations-extension.js └── src └── automations-extension.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /node_modules/ 3 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Build Release 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v3 19 | - name: Setup Node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '20' 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm run build --if-present 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zigbee2mqtt-extensions", 3 | "version": "2.0.0", 4 | "description": "Zigbee2MQTT Extensions", 5 | "devDependencies": { 6 | "@types/node": "^22.10.5", 7 | "typescript": "^5.7.3", 8 | "zigbee2mqtt": "^2.0.0" 9 | }, 10 | "scripts": { 11 | "start": "tsc --watch", 12 | "build": "tsc" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/Anonym-tsk/zigbee2mqtt-extensions.git" 17 | }, 18 | "author": "Anonym-tsk", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/Anonym-tsk/zigbee2mqtt-extensions/issues" 22 | }, 23 | "homepage": "https://github.com/Anonym-tsk/zigbee2mqtt-extensions#readme" 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nikolay Vasilchuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "lib": ["es2020"], 6 | 7 | "allowJs": false, 8 | "checkJs": false, 9 | "declaration": false, 10 | "sourceMap": false, 11 | "outDir": "./dist/", 12 | "rootDir": "./src/", 13 | "removeComments": true, 14 | "downlevelIteration": true, 15 | "isolatedModules": true, 16 | "skipLibCheck": true, 17 | 18 | "strict": true, 19 | "noImplicitAny": false, 20 | "strictNullChecks": true, 21 | "strictFunctionTypes": true, 22 | "noImplicitThis": true, 23 | "alwaysStrict": false, 24 | "ignoreDeprecations": "5.0", 25 | "types": ["node"], 26 | 27 | "noUnusedLocals": true, 28 | "noUnusedParameters": true, 29 | "noImplicitReturns": true, 30 | "noFallthroughCasesInSwitch": true, 31 | 32 | "moduleResolution": "node", 33 | "baseUrl": "./", 34 | 35 | "allowSyntheticDefaultImports": true, 36 | 37 | "experimentalDecorators": true, 38 | "strictPropertyInitialization": false 39 | }, 40 | "watchOptions": { 41 | "watchFile": "useFsEvents", 42 | "watchDirectory": "useFsEvents", 43 | "fallbackPolling": "dynamicPriority", 44 | "synchronousWatchDirectory": true, 45 | "excludeDirectories": ["**/node_modules", "dist"] 46 | }, 47 | "include": ["./src/"], 48 | "exclude": ["./node_modules/"] 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zigbee2MQTT Extensions 2 | 3 | --- 4 | 5 | Enjoy my work? [Help me out](https://yoomoney.ru/to/410019180291197) for a couple of :beers: or a :coffee:! 6 | 7 | [![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://yoomoney.ru/to/410019180291197) 8 | 9 | --- 10 | 11 | ## What are extensions? 12 | 13 | [Read this article](https://www.zigbee2mqtt.io/advanced/more/user_extensions.html) 14 | 15 | ## [automations-extension.js](dist/automations-extension.js) 16 | 17 | **Allows you to set up simple automations directly in z2m** 18 | 19 | _Example (add this into your z2m configuration.yaml):_ 20 | 21 | ```yaml 22 | automations: 23 | automation_by_action: 24 | trigger: 25 | platform: action 26 | entity: Test Switch 27 | action: single 28 | condition: 29 | platform: state 30 | entity: Test Switch 2 31 | state: ON 32 | action: 33 | entity: Test Plug 34 | service: toggle 35 | 36 | automation_by_state: 37 | trigger: 38 | platform: state 39 | entity: Test Plug 40 | state: ON 41 | action: 42 | entity: Test Plug 2 43 | service: turn_on 44 | 45 | automation_by_numeric_state: 46 | trigger: 47 | platform: numeric_state 48 | entity: Test Plug 49 | attribute: temperatire 50 | above: 17 51 | below: 26 52 | for: 3 53 | action: 54 | entity: Test Plug 55 | service: turn_on 56 | ``` 57 | 58 | _More complex example:_ 59 | 60 | ```yaml 61 | automations: 62 | automation_by_action: 63 | trigger: 64 | platform: action 65 | entity: 66 | - Test Switch 67 | - Test Button 68 | action: 69 | - single 70 | - double 71 | - hold 72 | condition: 73 | - platform: state 74 | entity: Test Switch 2 75 | state: ON 76 | - platform: numeric_state 77 | entity: My Sensor 78 | attribute: temperature 79 | above: 25 80 | below: 35 81 | action: 82 | - entity: Test Plug 83 | service: toggle 84 | - entity: Test Plug 2 85 | service: toggle 86 | 87 | automation_by_state: 88 | trigger: 89 | platform: state 90 | entity: 91 | - Test Plug 92 | - Test Plug 2 93 | state: 94 | - ON 95 | - OFF 96 | action: 97 | - entity: Test Light 1 98 | service: turn_on 99 | - entity: Test Light 2 100 | service: turn_off 101 | ``` 102 | 103 | #### Split configuration 104 | 105 | You can move automations to a separate file. 106 | Create file named `automations.yaml` and write all your automations there: 107 | 108 | ```yaml 109 | # configuration.yaml 110 | 111 | automations: automations.yaml 112 | ``` 113 | 114 | ```yaml 115 | # automations.yaml 116 | 117 | automation_by_action: 118 | trigger: 119 | platform: action 120 | entity: Test Switch 121 | action: single 122 | condition: 123 | platform: state 124 | entity: Test Switch 2 125 | state: ON 126 | action: 127 | entity: Test Plug 128 | service: toggle 129 | ``` 130 | 131 | 132 | #### State Trigger 133 | 134 | Fires when state of given entities changes. 135 | 136 | | Item | Type | Description | 137 | |-------------|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| 138 | | `platform` | `string` | `state` | 139 | | `entity` | `string` or `string[]` | Name of entity (friendly name) | 140 | | `state` | `string`, `string[]`, `number`, `number[]`, `boolean`, `boolean[]` | Depends on `attribute`. `ON`/`OFF` for `state`, `true`/`false` for `occupancy` | 141 | | `attribute` | `string` | Optional (default `state`). `temperatire`, `humidity`, `pressure` and others device-specific | 142 | | `for` | `number` | Number of seconds | 143 | 144 | _Examples:_ 145 | 146 | ```yaml 147 | trigger: 148 | platform: state 149 | entity: 150 | - My Switch 151 | - My Light 152 | state: ON 153 | for: 10 154 | ``` 155 | 156 | ```yaml 157 | trigger: 158 | platform: state 159 | entity: Motion Sensor 160 | attribute: occupancy 161 | state: true 162 | ``` 163 | 164 | 165 | #### Numeric State Trigger 166 | 167 | Fires when numeric attribute of given entities changes. Parameters `above` or `below` (or both) should be set. 168 | 169 | | Item | Type | Description | 170 | |-------------|------------------------|------------------------------------------------------------------| 171 | | `platform` | `string` | `numeric_state` | 172 | | `entity` | `string` or `string[]` | Name of entity (friendly name) | 173 | | `attribute` | `string` | `temperatire`, `humidity`, `pressure` and others device-specific | 174 | | `above` | `number` | Triggers when value crosses a given threshold | 175 | | `below` | `number` | Triggers when value crosses a given threshold | 176 | | `for` | `number` | Number of seconds | 177 | 178 | _Example:_ 179 | 180 | ```yaml 181 | trigger: 182 | platform: numeric_state 183 | entity: My Sensor 184 | attribute: temperature 185 | above: 25 186 | below: 35 187 | for: 180 188 | ``` 189 | 190 | ### Conditions 191 | 192 | Conditions are an optional part of an automation rule and can be used to prevent an action from happening when triggered. 193 | When a condition does not return true, the automation will stop executing. 194 | Conditions look very similar to triggers but are very different. 195 | A trigger will look at events happening in the system while a condition only looks at how the system looks right now. 196 | A trigger can observe that a switch is being turned on. A condition can only see if a switch is currently on or off. 197 | 198 | _Automation can have multiple conditions_ 199 | 200 | #### State Condition 201 | 202 | Tests if an entity is a specified state. 203 | 204 | | Item | Type | Description | 205 | |-------------|--------------------------------|----------------------------------------------------------------------------------------------| 206 | | `platform` | `string` | `state` | 207 | | `entity` | `string` | Name of entity (friendly name) | 208 | | `state` | `string`, `number`, `boolean` | Depends on `attribute`. `ON`/`OFF` for `state`, `true`/`false` for `occupancy` | 209 | | `attribute` | `string` | Optional (default `state`). `temperatire`, `humidity`, `pressure` and others device-specific | 210 | 211 | _Examples:_ 212 | 213 | ```yaml 214 | condition: 215 | platform: state 216 | entity: My Switch 217 | state: ON 218 | ``` 219 | 220 | ```yaml 221 | condition: 222 | platform: state 223 | entity: Motion Sensor 224 | attribute: occupancy 225 | state: false 226 | ``` 227 | 228 | 229 | #### Numeric State Condition 230 | 231 | This type of condition attempts to parse the attribute of an entity as a number, and triggers if the value matches the thresholds. 232 | 233 | If both `below` and `above` are specified, both tests have to pass. 234 | 235 | | Item | Type | Description | 236 | |-------------|----------|------------------------------------------------------------------| 237 | | `platform` | `string` | `numeric_state` | 238 | | `entity` | `string` | Name of entity (friendly name) | 239 | | `attribute` | `string` | `temperatire`, `humidity`, `pressure` and others device-specific | 240 | | `above` | `number` | Triggers when value crosses a given threshold | 241 | | `below` | `number` | Triggers when value crosses a given threshold | 242 | 243 | _Example:_ 244 | 245 | ```yaml 246 | condition: 247 | platform: numeric_state 248 | entity: My Sensor 249 | attribute: temperature 250 | above: 25 251 | below: 35 252 | ``` 253 | 254 | 255 | #### Time Condition 256 | 257 | The time condition can test if it is after a specified time, before a specified time or if it is a certain day of the week. 258 | 259 | | Item | Type | Description | 260 | |-------------|------------|--------------------------------------------------------------------------| 261 | | `platform` | `string` | `time` | 262 | | `after` | `string` | Optional (time in `hh:mm:ss` format) | 263 | | `before` | `string` | Optional (time in `hh:mm:ss` format) | 264 | | `weekday` | `string[]` | Optional (valid values: `mon`, `tue`, `wed`, `thu`, `fri`, `sat`, `sun`) | 265 | 266 | Note that if only `before` key is used, the condition will be `true` _from midnight_ until the specified time. 267 | If only `after` key is used, the condition will be `true` from the specified time _until midnight_. 268 | 269 | Time condition windows can span across the midnight threshold if both `after` and `before` keys are used. 270 | In the example below, the condition window is from 3pm to 2am. 271 | 272 | _Example:_ 273 | 274 | ```yaml 275 | condition: 276 | platform: time 277 | after: '15:00:00' 278 | before: '02:00:00' 279 | weekday: 280 | - mon 281 | - wed 282 | - fri 283 | ``` 284 | 285 | ### Actions 286 | 287 | The action of an automation rule is what is being executed when a rule fires. 288 | 289 | _Automation can have multiple actions_ 290 | 291 | | Item | Type | Description | 292 | |-----------|--------------------|---------------------------------------------| 293 | | `entity` | `string` | Name of entity (friendly name) | 294 | | `service` | `string` | `turn_on`, `turn_off`, `toggle` or `custom` | 295 | | `data` | `{string: string}` | Only for `service: custom`, see below | 296 | 297 | _Example:_ 298 | 299 | ```yaml 300 | action: 301 | - entity: Test Plug 302 | service: toggle 303 | - entity: Test Switch 304 | service: turn_on 305 | ``` 306 | 307 | #### Custom action 308 | 309 | You can call any service. Data will be transferred directly to Z2M. 310 | For example change brightness or turn on a relay with a custom name. 311 | 312 | _Example:_ 313 | 314 | ```yaml 315 | action: 316 | - entity: Plug With Two Relays 317 | service: custom 318 | data: 319 | state_l2: ON 320 | - entity: Light Strip 321 | service: custom 322 | data: 323 | state: ON 324 | brightness: 127 325 | transition: 2 326 | ``` 327 | -------------------------------------------------------------------------------- /dist/automations-extension.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const stringify = require("json-stable-stringify-without-jsonify"); 3 | const crypto = require("crypto"); 4 | const yaml_1 = require("../util/yaml"); 5 | const data_1 = require("../util/data"); 6 | function toArray(item) { 7 | return Array.isArray(item) ? item : [item]; 8 | } 9 | var ConfigPlatform; 10 | (function (ConfigPlatform) { 11 | ConfigPlatform["ACTION"] = "action"; 12 | ConfigPlatform["STATE"] = "state"; 13 | ConfigPlatform["NUMERIC_STATE"] = "numeric_state"; 14 | ConfigPlatform["TIME"] = "time"; 15 | })(ConfigPlatform || (ConfigPlatform = {})); 16 | var StateOnOff; 17 | (function (StateOnOff) { 18 | StateOnOff["ON"] = "ON"; 19 | StateOnOff["OFF"] = "OFF"; 20 | })(StateOnOff || (StateOnOff = {})); 21 | var ConfigService; 22 | (function (ConfigService) { 23 | ConfigService["TOGGLE"] = "toggle"; 24 | ConfigService["TURN_ON"] = "turn_on"; 25 | ConfigService["TURN_OFF"] = "turn_off"; 26 | ConfigService["CUSTOM"] = "custom"; 27 | })(ConfigService || (ConfigService = {})); 28 | const WEEK = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; 29 | const TIME_STRING_REGEXP = /^[0-9]{2}:[0-9]{2}:[0-9]{2}$/; 30 | class Time { 31 | constructor(time) { 32 | if (!time) { 33 | const now = new Date(); 34 | this.h = now.getHours(); 35 | this.m = now.getMinutes(); 36 | this.s = now.getSeconds(); 37 | } 38 | else if (!TIME_STRING_REGEXP.test(time)) { 39 | throw new Error(`Wrong time string: ${time}`); 40 | } 41 | else { 42 | [this.h, this.m, this.s] = time.split(':').map(Number); 43 | } 44 | } 45 | isEqual(time) { 46 | return this.h === time.h 47 | && this.m === time.m 48 | && this.s === time.s; 49 | } 50 | isGreater(time) { 51 | if (this.h > time.h) { 52 | return true; 53 | } 54 | if (this.h < time.h) { 55 | return false; 56 | } 57 | if (this.m > time.m) { 58 | return true; 59 | } 60 | if (this.m < time.m) { 61 | return false; 62 | } 63 | return this.s > time.s; 64 | } 65 | isLess(time) { 66 | return !this.isGreater(time) && !this.isEqual(time); 67 | } 68 | isInRange(after, before) { 69 | if (before.isEqual(after)) { 70 | return false; 71 | } 72 | if (this.isEqual(before) || this.isEqual(after)) { 73 | return true; 74 | } 75 | let inverse = false; 76 | if (after.isGreater(before)) { 77 | const tmp = after; 78 | after = before; 79 | before = tmp; 80 | inverse = true; 81 | } 82 | const result = this.isGreater(after) && this.isLess(before); 83 | return inverse ? !result : result; 84 | } 85 | } 86 | class InternalLogger { 87 | constructor(logger) { 88 | this.logger = logger; 89 | } 90 | log(level, ...args) { 91 | const data = args.map((item) => typeof item === 'string' ? item : stringify(item)).join(' '); 92 | this.logger[level](`[AutomationsExtension] ${data}`); 93 | } 94 | debug(...args) { 95 | this.log('debug', ...args); 96 | } 97 | warning(...args) { 98 | this.log('warning', ...args); 99 | } 100 | info(...args) { 101 | this.log('info', ...args); 102 | } 103 | error(...args) { 104 | this.log('error', ...args); 105 | } 106 | } 107 | class AutomationsExtension { 108 | constructor(zigbee, mqtt, state, publishEntityState, eventBus, enableDisableExtension, restartCallback, addExtension, settings, baseLogger) { 109 | this.zigbee = zigbee; 110 | this.mqtt = mqtt; 111 | this.state = state; 112 | this.publishEntityState = publishEntityState; 113 | this.eventBus = eventBus; 114 | this.enableDisableExtension = enableDisableExtension; 115 | this.restartCallback = restartCallback; 116 | this.addExtension = addExtension; 117 | this.settings = settings; 118 | this.logger = new InternalLogger(baseLogger); 119 | this.mqttBaseTopic = settings.get().mqtt.base_topic; 120 | this.automations = this.parseConfig(settings.get().automations || {}); 121 | this.timeouts = {}; 122 | this.logger.info('Plugin loaded'); 123 | this.logger.debug('Registered automations', this.automations); 124 | } 125 | parseConfig(automations) { 126 | if (typeof automations === 'string') { 127 | automations = (yaml_1.default.readIfExists(data_1.default.joinPath(automations)) || {}); 128 | } 129 | const services = Object.values(ConfigService); 130 | const platforms = Object.values(ConfigPlatform); 131 | return Object.values(automations).reduce((result, automation) => { 132 | const platform = automation.trigger.platform; 133 | if (!platforms.includes(platform)) { 134 | this.logger.warning(`Config validation error: unknown trigger platform '${platform}'`); 135 | return result; 136 | } 137 | if (!automation.trigger.entity) { 138 | this.logger.warning('Config validation error: trigger entity not specified'); 139 | return result; 140 | } 141 | const actions = toArray(automation.action); 142 | for (const action of actions) { 143 | if (!services.includes(action.service)) { 144 | this.logger.warning(`Config validation error: unknown service '${action.service}'`); 145 | return result; 146 | } 147 | } 148 | const conditions = automation.condition ? toArray(automation.condition) : []; 149 | for (const condition of conditions) { 150 | if (!platforms.includes(condition.platform)) { 151 | this.logger.warning(`Config validation error: unknown condition platform '${condition.platform}'`); 152 | return result; 153 | } 154 | } 155 | const entities = toArray(automation.trigger.entity); 156 | for (const entityId of entities) { 157 | if (!result[entityId]) { 158 | result[entityId] = []; 159 | } 160 | result[entityId].push({ 161 | id: crypto.randomUUID(), 162 | trigger: automation.trigger, 163 | action: actions, 164 | condition: conditions, 165 | }); 166 | } 167 | return result; 168 | }, {}); 169 | } 170 | checkTrigger(configTrigger, update, from, to) { 171 | let trigger; 172 | let attribute; 173 | switch (configTrigger.platform) { 174 | case ConfigPlatform.ACTION: 175 | if (!update.hasOwnProperty('action')) { 176 | return null; 177 | } 178 | trigger = configTrigger; 179 | const actions = toArray(trigger.action); 180 | return actions.includes(update.action); 181 | case ConfigPlatform.STATE: 182 | trigger = configTrigger; 183 | attribute = trigger.attribute || 'state'; 184 | if (!update.hasOwnProperty(attribute) || !from.hasOwnProperty(attribute) || !to.hasOwnProperty(attribute)) { 185 | return null; 186 | } 187 | if (from[attribute] === to[attribute]) { 188 | return null; 189 | } 190 | const states = toArray(trigger.state); 191 | return states.includes(update[attribute]); 192 | case ConfigPlatform.NUMERIC_STATE: 193 | trigger = configTrigger; 194 | attribute = trigger.attribute; 195 | if (!update.hasOwnProperty(attribute) || !from.hasOwnProperty(attribute) || !to.hasOwnProperty(attribute)) { 196 | return null; 197 | } 198 | if (from[attribute] === to[attribute]) { 199 | return null; 200 | } 201 | if (typeof trigger.above !== 'undefined') { 202 | if (to[attribute] < trigger.above) { 203 | return false; 204 | } 205 | if (from[attribute] >= trigger.above) { 206 | return null; 207 | } 208 | } 209 | if (typeof trigger.below !== 'undefined') { 210 | if (to[attribute] > trigger.below) { 211 | return false; 212 | } 213 | if (from[attribute] <= trigger.below) { 214 | return null; 215 | } 216 | } 217 | return true; 218 | } 219 | return false; 220 | } 221 | checkCondition(condition) { 222 | if (condition.platform === ConfigPlatform.TIME) { 223 | return this.checkTimeCondition(condition); 224 | } 225 | return this.checkEntityCondition(condition); 226 | } 227 | checkTimeCondition(condition) { 228 | const beforeStr = condition.before || '23:59:59'; 229 | const afterStr = condition.after || '00:00:00'; 230 | const weekday = condition.weekday || WEEK; 231 | try { 232 | const after = new Time(afterStr); 233 | const before = new Time(beforeStr); 234 | const current = new Time(); 235 | const now = new Date(); 236 | const day = now.getDay(); 237 | return current.isInRange(after, before) && weekday.includes(WEEK[day]); 238 | } 239 | catch (e) { 240 | this.logger.warning(e); 241 | return true; 242 | } 243 | } 244 | checkEntityCondition(condition) { 245 | if (!condition.entity) { 246 | this.logger.warning('Config validation error: condition entity not specified'); 247 | return true; 248 | } 249 | const entity = this.zigbee.resolveEntity(condition.entity); 250 | if (!entity) { 251 | this.logger.warning(`Condition not found for entity '${condition.entity}'`); 252 | return true; 253 | } 254 | let currentCondition; 255 | let currentState; 256 | let attribute; 257 | switch (condition.platform) { 258 | case ConfigPlatform.STATE: 259 | currentCondition = condition; 260 | attribute = currentCondition.attribute || 'state'; 261 | currentState = this.state.get(entity)[attribute]; 262 | if (currentState !== currentCondition.state) { 263 | return false; 264 | } 265 | break; 266 | case ConfigPlatform.NUMERIC_STATE: 267 | currentCondition = condition; 268 | attribute = currentCondition.attribute; 269 | currentState = this.state.get(entity)[attribute]; 270 | if (typeof currentCondition.above !== 'undefined' && currentState < currentCondition.above) { 271 | return false; 272 | } 273 | if (typeof currentCondition.below !== 'undefined' && currentState > currentCondition.below) { 274 | return false; 275 | } 276 | break; 277 | } 278 | return true; 279 | } 280 | runActions(actions) { 281 | for (const action of actions) { 282 | const destination = this.zigbee.resolveEntity(action.entity); 283 | if (!destination) { 284 | this.logger.debug(`Destination not found for entity '${action.entity}'`); 285 | continue; 286 | } 287 | const currentState = this.state.get(destination).state; 288 | let newState; 289 | switch (action.service) { 290 | case ConfigService.TURN_ON: 291 | newState = StateOnOff.ON; 292 | break; 293 | case ConfigService.TURN_OFF: 294 | newState = StateOnOff.OFF; 295 | break; 296 | case ConfigService.TOGGLE: 297 | newState = currentState === StateOnOff.ON ? StateOnOff.OFF : StateOnOff.ON; 298 | break; 299 | } 300 | let data; 301 | if (action.service === ConfigService.CUSTOM) { 302 | data = action.data; 303 | } 304 | else if (currentState === newState) { 305 | continue; 306 | } 307 | else { 308 | data = { state: newState }; 309 | } 310 | this.logger.debug(`Run automation for entity '${action.entity}':`, action); 311 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${destination.name}/set`, stringify(data)); 312 | } 313 | } 314 | runActionsWithConditions(conditions, actions) { 315 | for (const condition of conditions) { 316 | if (!this.checkCondition(condition)) { 317 | return; 318 | } 319 | } 320 | this.runActions(actions); 321 | } 322 | stopTimeout(automationId) { 323 | const timeout = this.timeouts[automationId]; 324 | if (timeout) { 325 | clearTimeout(timeout); 326 | delete this.timeouts[automationId]; 327 | } 328 | } 329 | startTimeout(automation, time) { 330 | this.logger.debug('Start timeout for automation', automation.trigger); 331 | const timeout = setTimeout(() => { 332 | delete this.timeouts[automation.id]; 333 | this.runActionsWithConditions(automation.condition, automation.action); 334 | }, time * 1000); 335 | timeout.unref(); 336 | this.timeouts[automation.id] = timeout; 337 | } 338 | runAutomationIfMatches(automation, update, from, to) { 339 | const triggerResult = this.checkTrigger(automation.trigger, update, from, to); 340 | if (triggerResult === false) { 341 | this.stopTimeout(automation.id); 342 | return; 343 | } 344 | if (triggerResult === null) { 345 | return; 346 | } 347 | this.logger.debug('Start automation', automation); 348 | const timeout = this.timeouts[automation.id]; 349 | if (timeout) { 350 | return; 351 | } 352 | if (automation.trigger.for) { 353 | this.startTimeout(automation, automation.trigger.for); 354 | return; 355 | } 356 | this.runActionsWithConditions(automation.condition, automation.action); 357 | } 358 | findAndRun(entityId, update, from, to) { 359 | const automations = this.automations[entityId]; 360 | if (!automations) { 361 | return; 362 | } 363 | for (const automation of automations) { 364 | this.runAutomationIfMatches(automation, update, from, to); 365 | } 366 | } 367 | async start() { 368 | this.eventBus.onStateChange(this, (data) => { 369 | this.findAndRun(data.entity.name, data.update, data.from, data.to); 370 | }); 371 | } 372 | async stop() { 373 | this.eventBus.removeListeners(this); 374 | } 375 | } 376 | module.exports = AutomationsExtension; 377 | -------------------------------------------------------------------------------- /src/automations-extension.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as stringify from 'json-stable-stringify-without-jsonify'; 3 | import * as crypto from 'crypto'; 4 | // @ts-ignore 5 | import yaml from '../util/yaml'; 6 | // @ts-ignore 7 | import data from '../util/data'; 8 | 9 | import type Zigbee from 'zigbee2mqtt/dist/zigbee'; 10 | import type MQTT from 'zigbee2mqtt/dist/mqtt'; 11 | import type State from 'zigbee2mqtt/dist/state'; 12 | import type EventBus from 'zigbee2mqtt/dist/eventBus'; 13 | import type Extension from 'zigbee2mqtt/dist/extension/extension'; 14 | import type Settings from 'zigbee2mqtt/dist/util/settings'; 15 | import type Logger from 'zigbee2mqtt/dist/util/logger'; 16 | 17 | function toArray(item: T | T[]): T[] { 18 | return Array.isArray(item) ? item : [item]; 19 | } 20 | 21 | enum ConfigPlatform { 22 | ACTION = 'action', 23 | STATE = 'state', 24 | NUMERIC_STATE = 'numeric_state', 25 | TIME = 'time', 26 | } 27 | 28 | enum StateOnOff { 29 | ON = 'ON', 30 | OFF = 'OFF', 31 | } 32 | 33 | enum ConfigService { 34 | TOGGLE = 'toggle', 35 | TURN_ON = 'turn_on', 36 | TURN_OFF = 'turn_off', 37 | CUSTOM = 'custom', 38 | } 39 | 40 | const WEEK = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; 41 | const TIME_STRING_REGEXP = /^[0-9]{2}:[0-9]{2}:[0-9]{2}$/; 42 | 43 | type ConfigStateType = string | number | boolean; 44 | type EntityId = string; 45 | type ConfigActionType = string; 46 | type ConfigAttribute = string; 47 | type Update = Record; 48 | type Second = number; 49 | type UUID = string; 50 | type TimeString = string; // e.g. "15:05:00" 51 | 52 | class Time { 53 | private readonly h: number; 54 | private readonly m: number; 55 | private readonly s: number; 56 | 57 | constructor(time?: TimeString) { 58 | if (!time) { 59 | const now = new Date(); 60 | this.h = now.getHours(); 61 | this.m = now.getMinutes(); 62 | this.s = now.getSeconds(); 63 | } else if (!TIME_STRING_REGEXP.test(time)) { 64 | throw new Error(`Wrong time string: ${time}`); 65 | } else { 66 | [this.h, this.m, this.s] = time.split(':').map(Number); 67 | } 68 | } 69 | 70 | isEqual(time: Time): boolean { 71 | return this.h === time.h 72 | && this.m === time.m 73 | && this.s === time.s; 74 | } 75 | 76 | isGreater(time: Time): boolean { 77 | if (this.h > time.h) { 78 | return true; 79 | } 80 | if (this.h < time.h) { 81 | return false; 82 | } 83 | if (this.m > time.m) { 84 | return true; 85 | } 86 | if (this.m < time.m) { 87 | return false; 88 | } 89 | return this.s > time.s; 90 | } 91 | 92 | isLess(time: Time) { 93 | return !this.isGreater(time) && !this.isEqual(time); 94 | } 95 | 96 | isInRange(after: Time, before: Time): boolean { 97 | if (before.isEqual(after)) { 98 | return false; 99 | } 100 | 101 | // Граничные значения считаем всегда подходящими 102 | if (this.isEqual(before) || this.isEqual(after)) { 103 | return true; 104 | } 105 | 106 | let inverse = false; 107 | // Если интервал переходит через 00:00, инвертируем его 108 | if (after.isGreater(before)) { 109 | const tmp = after; 110 | after = before; 111 | before = tmp; 112 | inverse = true; 113 | } 114 | 115 | const result = this.isGreater(after) && this.isLess(before); 116 | return inverse ? !result : result; 117 | } 118 | } 119 | 120 | interface ConfigTrigger { 121 | platform: ConfigPlatform; 122 | entity: EntityId | EntityId[]; 123 | for?: Second; 124 | } 125 | 126 | interface ConfigActionTrigger extends ConfigTrigger { 127 | action: ConfigActionType | ConfigActionType[]; 128 | } 129 | 130 | interface ConfigStateTrigger extends ConfigTrigger { 131 | attribute?: ConfigAttribute; 132 | state: ConfigStateType | ConfigStateType[]; 133 | } 134 | 135 | interface ConfigNumericStateTrigger extends ConfigTrigger { 136 | attribute: ConfigAttribute; 137 | above?: number; 138 | below?: number; 139 | } 140 | 141 | type ConfigActionData = Record; 142 | 143 | interface ConfigAction { 144 | entity: EntityId; 145 | service: ConfigService; 146 | data?: ConfigActionData; 147 | } 148 | 149 | interface ConfigCondition { 150 | platform: ConfigPlatform; 151 | } 152 | 153 | interface ConfigEntityCondition extends ConfigCondition { 154 | entity: EntityId; 155 | } 156 | 157 | interface ConfigStateCondition extends ConfigEntityCondition { 158 | attribute?: ConfigAttribute; 159 | state: ConfigStateType; 160 | } 161 | 162 | interface ConfigNumericStateCondition extends ConfigEntityCondition { 163 | attribute: ConfigAttribute; 164 | above?: number; 165 | below?: number; 166 | } 167 | 168 | interface ConfigTimeCondition extends ConfigCondition { 169 | after?: TimeString; 170 | before?: TimeString; 171 | weekday?: string[]; 172 | } 173 | 174 | type ConfigAutomations = { 175 | [key: string]: { 176 | trigger: ConfigTrigger, 177 | action: ConfigAction | ConfigAction[], 178 | condition?: ConfigCondition | ConfigCondition[], 179 | } 180 | }; 181 | 182 | type Automation = { 183 | id: UUID, 184 | trigger: ConfigTrigger, 185 | action: ConfigAction[], 186 | condition: ConfigCondition[], 187 | }; 188 | 189 | type Automations = { 190 | [key: EntityId]: Automation[], 191 | }; 192 | 193 | class InternalLogger { 194 | constructor(private logger: typeof Logger) {} 195 | 196 | private log(level: 'warning' | 'debug' | 'info' | 'error', ...args: unknown[]): void { 197 | const data = args.map((item) => typeof item === 'string' ? item : stringify(item)).join(' '); 198 | this.logger[level](`[AutomationsExtension] ${data}`); 199 | } 200 | 201 | debug(...args: unknown[]): void { 202 | this.log('debug', ...args); 203 | } 204 | 205 | warning(...args: unknown[]): void { 206 | this.log('warning', ...args); 207 | } 208 | 209 | info(...args: unknown[]): void { 210 | this.log('info', ...args); 211 | } 212 | 213 | error(...args: unknown[]): void { 214 | this.log('error', ...args); 215 | } 216 | } 217 | 218 | class AutomationsExtension { 219 | private readonly mqttBaseTopic: string; 220 | private readonly automations: Automations; 221 | private readonly timeouts: Record; 222 | private readonly logger: InternalLogger; 223 | 224 | constructor( 225 | protected zigbee: Zigbee, 226 | protected mqtt: MQTT, 227 | protected state: State, 228 | protected publishEntityState: unknown, 229 | protected eventBus: EventBus, 230 | protected enableDisableExtension: (enable: boolean, name: string) => Promise, 231 | protected restartCallback: () => Promise, 232 | protected addExtension: (extension: Extension) => Promise, 233 | protected settings: typeof Settings, 234 | baseLogger: typeof Logger, 235 | ) { 236 | this.logger = new InternalLogger(baseLogger); 237 | this.mqttBaseTopic = settings.get().mqtt.base_topic; 238 | this.automations = this.parseConfig(settings.get().automations || {}); 239 | this.timeouts = {}; 240 | 241 | this.logger.info('Plugin loaded'); 242 | this.logger.debug('Registered automations', this.automations); 243 | } 244 | 245 | private parseConfig(automations: ConfigAutomations | string): Automations { 246 | if (typeof automations === 'string') { 247 | automations = (yaml.readIfExists(data.joinPath(automations)) || {}) as ConfigAutomations; 248 | } 249 | 250 | const services = Object.values(ConfigService); 251 | const platforms = Object.values(ConfigPlatform); 252 | 253 | return Object.values(automations).reduce((result, automation) => { 254 | const platform = automation.trigger.platform; 255 | if (!platforms.includes(platform)) { 256 | this.logger.warning(`Config validation error: unknown trigger platform '${platform}'`); 257 | return result; 258 | } 259 | 260 | if (!automation.trigger.entity) { 261 | this.logger.warning('Config validation error: trigger entity not specified'); 262 | return result; 263 | } 264 | 265 | const actions = toArray(automation.action); 266 | for (const action of actions) { 267 | if (!services.includes(action.service)) { 268 | this.logger.warning(`Config validation error: unknown service '${action.service}'`); 269 | return result; 270 | } 271 | } 272 | 273 | const conditions = automation.condition ? toArray(automation.condition) : []; 274 | for (const condition of conditions) { 275 | if (!platforms.includes(condition.platform)) { 276 | this.logger.warning(`Config validation error: unknown condition platform '${condition.platform}'`); 277 | return result; 278 | } 279 | } 280 | 281 | const entities = toArray(automation.trigger.entity); 282 | for (const entityId of entities) { 283 | if (!result[entityId]) { 284 | result[entityId] = []; 285 | } 286 | 287 | result[entityId].push({ 288 | id: crypto.randomUUID(), 289 | trigger: automation.trigger, 290 | action: actions, 291 | condition: conditions, 292 | }); 293 | } 294 | 295 | return result; 296 | }, {} as Automations); 297 | } 298 | 299 | /** 300 | * Возвращаемые значения: 301 | * null - update не удовлетворяет условиям триггера 302 | * true - проверка прошла, триггер сработал 303 | * false - проверка не прошла, триггер не сработал 304 | */ 305 | private checkTrigger(configTrigger: ConfigTrigger, update: Update, from: Update, to: Update): boolean | null { 306 | let trigger; 307 | let attribute; 308 | 309 | switch (configTrigger.platform) { 310 | case ConfigPlatform.ACTION: 311 | if (!update.hasOwnProperty('action')) { 312 | return null; 313 | } 314 | 315 | trigger = configTrigger as ConfigActionTrigger; 316 | const actions = toArray(trigger.action); 317 | 318 | return actions.includes(update.action as ConfigActionType); 319 | 320 | case ConfigPlatform.STATE: 321 | trigger = configTrigger as ConfigStateTrigger; 322 | attribute = trigger.attribute || 'state'; 323 | 324 | if (!update.hasOwnProperty(attribute) || !from.hasOwnProperty(attribute) || !to.hasOwnProperty(attribute)) { 325 | return null; 326 | } 327 | 328 | if (from[attribute] === to[attribute]) { 329 | return null; 330 | } 331 | 332 | const states = toArray(trigger.state); 333 | return states.includes(update[attribute] as ConfigStateType); 334 | 335 | case ConfigPlatform.NUMERIC_STATE: 336 | trigger = configTrigger as ConfigNumericStateTrigger; 337 | attribute = trigger.attribute; 338 | 339 | if (!update.hasOwnProperty(attribute) || !from.hasOwnProperty(attribute) || !to.hasOwnProperty(attribute)) { 340 | return null; 341 | } 342 | 343 | if (from[attribute] === to[attribute]) { 344 | return null; 345 | } 346 | 347 | if (typeof trigger.above !== 'undefined') { 348 | if (to[attribute] < trigger.above) { 349 | return false; 350 | } 351 | if (from[attribute] >= trigger.above) { 352 | return null; 353 | } 354 | } 355 | 356 | if (typeof trigger.below !== 'undefined') { 357 | if (to[attribute] > trigger.below) { 358 | return false; 359 | } 360 | if (from[attribute] <= trigger.below) { 361 | return null; 362 | } 363 | } 364 | 365 | return true; 366 | } 367 | 368 | return false; 369 | } 370 | 371 | private checkCondition(condition: ConfigCondition): boolean { 372 | if (condition.platform === ConfigPlatform.TIME) { 373 | return this.checkTimeCondition(condition as ConfigTimeCondition); 374 | } 375 | return this.checkEntityCondition(condition as ConfigEntityCondition); 376 | } 377 | 378 | private checkTimeCondition(condition: ConfigTimeCondition): boolean { 379 | const beforeStr = condition.before || '23:59:59'; 380 | const afterStr = condition.after || '00:00:00'; 381 | const weekday = condition.weekday || WEEK; 382 | 383 | try { 384 | const after = new Time(afterStr); 385 | const before = new Time(beforeStr); 386 | const current = new Time() 387 | const now = new Date(); 388 | const day = now.getDay(); 389 | return current.isInRange(after, before) && weekday.includes(WEEK[day]); 390 | } catch (e: any) { 391 | this.logger.warning(e); 392 | return true; 393 | } 394 | } 395 | 396 | private checkEntityCondition(condition: ConfigEntityCondition): boolean { 397 | if (!condition.entity) { 398 | this.logger.warning('Config validation error: condition entity not specified'); 399 | return true; 400 | } 401 | 402 | const entity = this.zigbee.resolveEntity(condition.entity); 403 | if (!entity) { 404 | this.logger.warning(`Condition not found for entity '${condition.entity}'`); 405 | return true; 406 | } 407 | 408 | let currentCondition; 409 | let currentState; 410 | let attribute; 411 | 412 | switch (condition.platform) { 413 | case ConfigPlatform.STATE: 414 | currentCondition = condition as ConfigStateCondition; 415 | attribute = currentCondition.attribute || 'state'; 416 | currentState = this.state.get(entity)[attribute]; 417 | 418 | if (currentState !== currentCondition.state) { 419 | return false; 420 | } 421 | 422 | break; 423 | 424 | case ConfigPlatform.NUMERIC_STATE: 425 | currentCondition = condition as ConfigNumericStateCondition; 426 | attribute = currentCondition.attribute; 427 | currentState = this.state.get(entity)[attribute]; 428 | 429 | if (typeof currentCondition.above !== 'undefined' && currentState < currentCondition.above) { 430 | return false; 431 | } 432 | 433 | if (typeof currentCondition.below !== 'undefined' && currentState > currentCondition.below) { 434 | return false; 435 | } 436 | 437 | break; 438 | } 439 | 440 | return true; 441 | } 442 | 443 | private runActions(actions: ConfigAction[]): void { 444 | for (const action of actions) { 445 | const destination = this.zigbee.resolveEntity(action.entity); 446 | if (!destination) { 447 | this.logger.debug(`Destination not found for entity '${action.entity}'`); 448 | continue; 449 | } 450 | 451 | const currentState = this.state.get(destination).state; 452 | let newState; 453 | 454 | switch (action.service) { 455 | case ConfigService.TURN_ON: 456 | newState = StateOnOff.ON; 457 | break; 458 | 459 | case ConfigService.TURN_OFF: 460 | newState = StateOnOff.OFF; 461 | break; 462 | 463 | case ConfigService.TOGGLE: 464 | newState = currentState === StateOnOff.ON ? StateOnOff.OFF : StateOnOff.ON; 465 | break; 466 | } 467 | 468 | let data: ConfigActionData; 469 | if (action.service === ConfigService.CUSTOM) { 470 | data = action.data as ConfigActionData; 471 | } else if (currentState === newState) { 472 | continue; 473 | } else { 474 | data = {state: newState}; 475 | } 476 | 477 | this.logger.debug(`Run automation for entity '${action.entity}':`, action); 478 | this.mqtt.onMessage(`${this.mqttBaseTopic}/${destination.name}/set`, stringify(data)); 479 | } 480 | } 481 | 482 | private runActionsWithConditions(conditions: ConfigCondition[], actions: ConfigAction[]): void { 483 | for (const condition of conditions) { 484 | if (!this.checkCondition(condition)) { 485 | return; 486 | } 487 | } 488 | 489 | this.runActions(actions); 490 | } 491 | 492 | private stopTimeout(automationId: UUID): void { 493 | const timeout = this.timeouts[automationId]; 494 | if (timeout) { 495 | clearTimeout(timeout); 496 | delete this.timeouts[automationId]; 497 | } 498 | } 499 | 500 | private startTimeout(automation: Automation, time: Second): void { 501 | this.logger.debug('Start timeout for automation', automation.trigger); 502 | 503 | const timeout = setTimeout(() => { 504 | delete this.timeouts[automation.id]; 505 | this.runActionsWithConditions(automation.condition, automation.action); 506 | }, time * 1000); 507 | timeout.unref(); 508 | 509 | this.timeouts[automation.id] = timeout; 510 | } 511 | 512 | private runAutomationIfMatches(automation: Automation, update: Update, from: Update, to: Update): void { 513 | const triggerResult = this.checkTrigger(automation.trigger, update, from, to); 514 | if (triggerResult === false) { 515 | this.stopTimeout(automation.id); 516 | return; 517 | } 518 | if (triggerResult === null) { 519 | return; 520 | } 521 | 522 | this.logger.debug('Start automation', automation); 523 | 524 | const timeout = this.timeouts[automation.id]; 525 | if (timeout) { 526 | return; 527 | } 528 | 529 | if (automation.trigger.for) { 530 | this.startTimeout(automation, automation.trigger.for); 531 | return; 532 | } 533 | 534 | this.runActionsWithConditions(automation.condition, automation.action); 535 | } 536 | 537 | private findAndRun(entityId: EntityId, update: Update, from: Update, to: Update): void { 538 | const automations = this.automations[entityId]; 539 | if (!automations) { 540 | return; 541 | } 542 | 543 | for (const automation of automations) { 544 | this.runAutomationIfMatches(automation, update, from, to); 545 | } 546 | } 547 | 548 | async start() { 549 | this.eventBus.onStateChange(this, (data: any) => { 550 | this.findAndRun(data.entity.name, data.update, data.from, data.to); 551 | }); 552 | } 553 | 554 | async stop() { 555 | this.eventBus.removeListeners(this); 556 | } 557 | } 558 | 559 | export = AutomationsExtension; 560 | --------------------------------------------------------------------------------