├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── __mocks__ ├── data │ ├── auth.json │ └── location.json └── node-fetch.js ├── config.schema.json ├── deploy ├── deploy.sh └── update.sh ├── docs ├── homebridge-tion-page.png ├── homekit-demo.png ├── homekit-demo1.png ├── homekit-demo2.png ├── homekit-demo3.png └── homekit-demo4.png ├── package-lock.json ├── package.json ├── src ├── accessories_factory.ts ├── homebridge │ └── framework.ts ├── index.ts ├── platform.ts ├── platform_config.ts └── tion │ ├── api.ts │ ├── auth.ts │ ├── command.ts │ ├── devices │ ├── base.ts │ ├── breezer.ts │ ├── co2plus.ts │ ├── factory.ts │ ├── station.ts │ └── supported_device_types.ts │ └── state.ts ├── test ├── config.test.ts ├── data │ └── homebridge-tion.auth.json ├── mocks.ts ├── platform.test.ts └── tion_api.test.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __mocks__ 2 | .vscode 3 | .idea 4 | docs 5 | src 6 | test 7 | examples 8 | .gitignore 9 | .npmignore 10 | tsconfig.json 11 | tslint.json 12 | *.tgz 13 | deploy 14 | .prettierrc -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 120, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "tabWidth": 4, 7 | "bracketSpacing": false, 8 | "overrides": [ 9 | { 10 | "files": "*.ts", 11 | "options": { 12 | "parser": "typescript", 13 | "arrowParens": "avoid" 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest All", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": ["--runInBand"], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "disableOptimisticBPs": true, 16 | "windows": { 17 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 18 | } 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "Jest Current File", 24 | "program": "${workspaceFolder}/node_modules/.bin/jest", 25 | "args": [ 26 | "${file}" 27 | ], 28 | "console": "integratedTerminal", 29 | "internalConsoleOptions": "neverOpen", 30 | "disableOptimisticBPs": true, 31 | "windows": { 32 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 33 | } 34 | }, 35 | { 36 | "type": "node", 37 | "request": "launch", 38 | "name": "Launch Program", 39 | "skipFiles": [ 40 | "/**", "node_modules/**" 41 | ], 42 | "args": ["-I", "-D", "-U", "${workspaceFolder}/../homebridge"], 43 | "env": {"DEBUG": "*"}, 44 | "program": "${workspaceFolder}/../homebridge/bin/homebridge", 45 | "preLaunchTask": "npm: build", 46 | "outFiles": [ 47 | "${workspaceFolder}/dist/**/*.js", 48 | ], 49 | "outputCapture": "std", 50 | "console": "integratedTerminal", 51 | "internalConsoleOptions": "neverOpen" 52 | } 53 | ] 54 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "build", 7 | "group": "build", 8 | "problemMatcher": [], 9 | "label": "npm: build", 10 | "detail": "ttsc" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ilya Ruzakov 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-tion 2 | 3 | [![NPM](https://nodei.co/npm/homebridge-tion.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/homebridge-tion/) 4 | 5 | [![npm](https://img.shields.io/npm/dm/homebridge-tion.svg)](https://www.npmjs.com/package/homebridge-tion) 6 | [![npm](https://img.shields.io/npm/v/homebridge-tion.svg)](https://www.npmjs.com/package/homebridge-tion) 7 | 8 | Плагин для [Homebridge](https://github.com/nfarina/homebridge), позволяющий управлять климатической техникой [Tion](https://tion.ru/product/magicair/) (базовой станций MagicAir, бризерами 3S и O2). 9 | 10 | ## Поддерживаемые устройства 11 | 12 | | Модель | 13 | |--------| 14 | | [Базовая станция MagicAir](https://tion.ru/product/magicair/) | 15 | | [Бризер Тион 4S](https://tion.ru/product/breezer-tion-4s/) | 16 | | [Бризер Тион 3S](https://tion.ru/product/breezer-tion-3s/) | 17 | | [Бризер Тион O2](https://tion.ru/product/breezer-tion-o2/) | 18 | | [Модуль CO2](https://tion.ru/product/co2plus/) | 19 | 20 | Для устройств, которых нет в списке, поддержка пока не реализована. Если вы хотите добавить поддержку новых устройства, свяжитесь со мной или предложите pull-request. 21 | 22 | ## Функции 23 | 24 | - управление бризером (вкл/выкл, скорость, нагрев, температура, рециркуляция) 25 | - мониторинг состояния фильтра бризера 26 | - мониторинг качества воздуха (уровень CO2, температура, влажность) 27 | - управление подсветкой базовой станции 28 | - сигнализация превышения допустимого уровня CO2 29 | - мониторинг температуры уличного воздуха 30 | 31 | | ![Демо](docs/homekit-demo.png) | ![Демо](docs/homekit-demo1.png) | ![Демо](docs/homekit-demo2.png) | 32 | | ------------------------------------------- | ------------------------------------------- | -------------------------------------------- | 33 | | ![Демо](docs/homekit-demo3.png) | ![Демо](docs/homekit-demo4.png) | 34 | 35 | ## Установка 36 | 37 | 0. Настройте базовую станцию: 38 | 39 | Из [веб-интерфейса](https://magicair.tion.ru) или из приложения для [iOS](https://apps.apple.com/ru/app/magicair/id1111104830) или [Android](https://play.google.com/store/apps/details?id=com.tion.magicair) 40 | 41 | 1. Если у вас уже есть [Config UI X](https://github.com/oznu/homebridge-config-ui-x), рекомендую устанавливать и конфигурировать плагин с его помощью, а дальнейшие шаги можно пропустить. 42 | 43 | ![Настройки](docs/homebridge-tion-page.png) 44 | 45 | 2. Установите плагин: 46 | 47 | ```shell 48 | $ npm install -g homebridge-tion --production 49 | ``` 50 | 51 | 3. Обновите конфигурацию Homebridge: 52 | 53 | Добавьте в секцию `platforms` следующую запись (см. описание полей ниже): 54 | 55 | ```json 56 | "platforms": [ 57 | { 58 | "platform": "Tion", 59 | "name": "Tion", 60 | "homeName": ИМЯ_ДОМА_В_MAGICAIR_В_ДВОЙНЫХ_КАВЫЧКАХ, 61 | "userName": ИМЯ_ПОЛЬЗОВАТЕЛЯ_В_MAGICAIR_В_ДВОЙНЫХ_КАВЫЧКАХ, 62 | "password": ПАРОЛЬ_В_MAGICAIR_В_ДВОЙНЫХ_КАВЫЧКАХ 63 | } 64 | ] 65 | ``` 66 | 67 | 4. Перезапустите Homebridge 68 | 69 | 70 | 71 | ## Конфигурация 72 | 73 | | Поле | Тип | Описание | Обязательно поле | Значение по-умолчанию | 74 | |--------|------|-------------|----------|---------------| 75 | | `name` | `string` | Имя плагина в Homebridge | Да | `Tion` | 76 | | `homeName` | `string` | Имя дома в MagicAir. Если у вас один дом в MagicAir, оставьте это поле пустым. Если у вас несколько домов, укажите имя дома в MagicAir, приборами в котором вы хотите управлять. | Нет | | 77 | | `userName` | `string` | Имя пользователя в MagicAir | Да | | 78 | | `password` | `string` | Пароль в MagicAir | Да | | 79 | | `co2Threshold` | `number` | Уровень CO2 (ppm), выше которого будет сигнализировать датчик CO2 в Homekit | Нет | `800` | 80 | | `percentSpeed` | `boolean` | Задаёт вид регулировки скорости бризера в процентах (0-100%, как принято в Homekit) или фиксированными значениями (1-4, 1-6, как принято в MagicAir) | Нет | `false` | 81 | | `apiRequestTimeout` | `number` | Таймаут (мс), по истечении которого запрос к серверу MagicAir принудительно завершается с ошибкой | Нет | `1500` | 82 | 83 | ## Todo 84 | 85 | - [ ] связь с базовой станцией напрямую (локальный режим) 86 | 87 | ## Disclaimer 88 | 89 | > Плагин не является заменой официального приложения MagicAir. 90 | 91 | > Я не имею никакого отношения к компании Tion. 92 | 93 | > Мне просто нравится климатическая техника Tion и хочется качественного UX с моими устройствами Apple. 94 | 95 | ## Автор 96 | 97 | Илья Рузаков 98 | 99 | [t.me/break-pointer](https://t.me/break-pointer) -------------------------------------------------------------------------------- /__mocks__/data/auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "512c24bd76ea4b16b5367030233b599bf043c284fcf448719bbdd52698e12605", 3 | "expires_in": 1296000, 4 | "token_type": "Bearer", 5 | "refresh_token": "32831263e7ec4167943a05bf6de9deadb77a043bb9e646389e796710742fd798", 6 | "username": "admin@google.com", 7 | "user_guid": "37b6c2bc-5b71-4e5e-a44a-1cd890783e77", 8 | "client_id": "a750d720-e146-47b0-b414-35e3b1dd7862", 9 | ".issued": "2019-09-29T10:20:51.6736311Z", 10 | ".expires": "2019-10-14T10:20:51.6736311Z" 11 | } -------------------------------------------------------------------------------- /__mocks__/data/location.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "guid": "27bcdb8d-9af5-419e-b044-f979a92c5850", 4 | "name": "House", 5 | "comment": null, 6 | "timezone": 18000, 7 | "type": "unknown", 8 | "access_level": "owner", 9 | "repository": "production", 10 | "mac": "00:00:00:00:00:00", 11 | "connection": { 12 | "state": "noConnection", 13 | "is_online": false, 14 | "last_seen_iso": "0001-01-01T00:00:00.0000000", 15 | "last_seen": 0, 16 | "last_packet_time_iso": "0001-01-01T00:00:00.0000000", 17 | "last_packet_time": 0, 18 | "data_state": "noData", 19 | "last_seen_delta": 1569745527 20 | }, 21 | "update": { 22 | "state": "unknown", 23 | "device_type": "unknown", 24 | "mac": 0, 25 | "mac_human": "00:00:00:00:00:00", 26 | "progress": 0 27 | }, 28 | "unique_key": "a177f547-f10d-4250-8e94-f58b6d34dabd", 29 | "replace_in_progress": false, 30 | "struct_received": false, 31 | "order": 0, 32 | "zones": [ 33 | { 34 | "guid": "d9f88238-e25a-453a-9069-bc51abe44410", 35 | "name": "", 36 | "type": "unkown", 37 | "color": "", 38 | "is_virtual": true, 39 | "mode": { 40 | "current": "off", 41 | "auto_set": { 42 | "co2": 0, 43 | "temperature": 0, 44 | "humidity": 0, 45 | "noise": 0, 46 | "pm25": 0, 47 | "pm10": 0 48 | } 49 | }, 50 | "schedule": { 51 | "is_schedule_sync": true, 52 | "is_active": false, 53 | "is_mode_sync": false, 54 | "current_preset": {}, 55 | "next_preset_starts_at": 0, 56 | "next_starts_iso": "1970-01-01T00:00:00.0000000Z" 57 | }, 58 | "sensors_average": [ 59 | { 60 | "data_type": "co2th", 61 | "have_sensors": [], 62 | "data": { 63 | "co2": "NaN", 64 | "temperature": "NaN", 65 | "humidity": "NaN", 66 | "pm25": "NaN", 67 | "pm10": "NaN", 68 | "radon": 0, 69 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 70 | "measurement_time": 1569745525 71 | } 72 | } 73 | ], 74 | "hw_id": 0, 75 | "devices": [], 76 | "order": 1, 77 | "creation_time_iso": "2019-08-29T08:06:23.0577927Z", 78 | "creation_time": 1567065983, 79 | "update_time_iso": "2019-08-29T08:06:23.0577927Z", 80 | "update_time": 1567065983 81 | } 82 | ], 83 | "creation_time_iso": "2019-09-11T07:08:37.8893743Z", 84 | "creation_time": 1568185717, 85 | "update_time_iso": "2019-09-11T07:08:37.8893743Z", 86 | "update_time": 1568185717 87 | }, 88 | { 89 | "guid": "6df744d7-93f9-4c8c-b2eb-0187bb520eab", 90 | "name": "Home", 91 | "comment": null, 92 | "timezone": 18000, 93 | "type": "unknown", 94 | "access_level": "owner", 95 | "repository": "production", 96 | "mac": "53:4D:76:31:39:50", 97 | "connection": { 98 | "state": "connected", 99 | "is_online": true, 100 | "last_seen_iso": "2019-09-29T08:25:19.3732791Z", 101 | "last_seen": 1569745519, 102 | "last_packet_time_iso": "2019-09-29T08:25:19.3732791Z", 103 | "last_packet_time": 1569745519, 104 | "data_state": "valid", 105 | "last_seen_delta": 0 106 | }, 107 | "update": { 108 | "state": "no", 109 | "device_type": "unknown", 110 | "mac": 0, 111 | "mac_human": "00:00:00:00:00:00", 112 | "progress": 0 113 | }, 114 | "unique_key": "3cbd4c4b-0408-4b37-9985-139fa849ea1d", 115 | "replace_in_progress": false, 116 | "struct_received": true, 117 | "order": 0, 118 | "zones": [ 119 | { 120 | "guid": "d9f88238-e25a-453a-9069-bc51abe44410", 121 | "name": "", 122 | "type": "unkown", 123 | "color": "", 124 | "is_virtual": true, 125 | "mode": { 126 | "current": "off", 127 | "auto_set": { 128 | "co2": 0, 129 | "temperature": 0, 130 | "humidity": 0, 131 | "noise": 0, 132 | "pm25": 0, 133 | "pm10": 0 134 | } 135 | }, 136 | "schedule": { 137 | "is_schedule_sync": true, 138 | "is_active": false, 139 | "is_mode_sync": false, 140 | "current_preset": {}, 141 | "next_preset_starts_at": 0, 142 | "next_starts_iso": "1970-01-01T00:00:00.0000000Z" 143 | }, 144 | "sensors_average": [ 145 | { 146 | "data_type": "co2th", 147 | "have_sensors": [], 148 | "data": { 149 | "co2": "NaN", 150 | "temperature": "NaN", 151 | "humidity": "NaN", 152 | "pm25": "NaN", 153 | "pm10": "NaN", 154 | "radon": 0, 155 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 156 | "measurement_time": 1569745525 157 | } 158 | } 159 | ], 160 | "hw_id": 0, 161 | "devices": [], 162 | "order": 1, 163 | "creation_time_iso": "2019-08-29T08:06:23.0577927Z", 164 | "creation_time": 1567065983, 165 | "update_time_iso": "2019-08-29T08:06:23.0577927Z", 166 | "update_time": 1567065983 167 | }, 168 | { 169 | "guid": "3291ce4a-4c35-4894-8f9a-6223f6d8741f", 170 | "name": "Bedroom", 171 | "type": "unkown", 172 | "color": "00ccff", 173 | "is_virtual": false, 174 | "mode": { 175 | "current": "manual", 176 | "auto_set": { 177 | "co2": 800, 178 | "temperature": 0, 179 | "humidity": 0, 180 | "noise": 0, 181 | "pm25": 0, 182 | "pm10": 0 183 | } 184 | }, 185 | "schedule": { 186 | "is_schedule_sync": true, 187 | "is_active": false, 188 | "is_mode_sync": false, 189 | "current_preset": {}, 190 | "next_preset_starts_at": 0, 191 | "next_starts_iso": "1970-01-01T00:00:00.0000000Z" 192 | }, 193 | "sensors_average": [ 194 | { 195 | "data_type": "co2th", 196 | "have_sensors": ["temperature", "humidity", "co2"], 197 | "data": { 198 | "co2": 775, 199 | "temperature": 22.57, 200 | "humidity": 34.27, 201 | "pm25": "NaN", 202 | "pm10": "NaN", 203 | "radon": 0, 204 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 205 | "measurement_time": 1569745525 206 | } 207 | } 208 | ], 209 | "hw_id": 718041192, 210 | "devices": [ 211 | { 212 | "guid": "908d521f-4fc5-4715-b25e-62226c1e5755", 213 | "name": "MagicAir", 214 | "type": "co2mb", 215 | "subtype_d": 32769, 216 | "control_type": "self", 217 | "mac": "53:4D:76:31:39:50", 218 | "mac_long": 88206573194579, 219 | "is_online": true, 220 | "last_seen_delta": 0, 221 | "zone_hwid": 718041192, 222 | "serial_number": "", 223 | "order": 0, 224 | "data": { 225 | "status": "application", 226 | "wi-fi": 120, 227 | "pairing": { 228 | "stage": "off", 229 | "time_left": 0, 230 | "pairing_result": false, 231 | "mac": "00:00:00:00:00:00", 232 | "device_type": "unknown", 233 | "subtype": "0000", 234 | "subtype_d": 0 235 | }, 236 | "co2": 775, 237 | "temperature": 22.57, 238 | "humidity": 34.27, 239 | "pm25": "NaN", 240 | "pm10": "NaN", 241 | "signal_level": 0, 242 | "backlight": 0, 243 | "reliability_code": "0x00000007", 244 | "last_seen_iso": "2019-09-29T08:25:25.0000000", 245 | "last_seen": 1569745525, 246 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 247 | "measurement_time": 1569745525 248 | }, 249 | "firmware": "03F5", 250 | "hardware": "0001", 251 | "creation_time": 1567065983, 252 | "update_time": 1568875826 253 | }, 254 | { 255 | "guid": "a6e6406f-06b3-45b7-85de-3884835eacf4", 256 | "name": "Вентилятор", 257 | "type": "breezer3", 258 | "subtype_d": 0, 259 | "control_type": "rf", 260 | "mac": "4B:24:7F:DB:1A:2F", 261 | "mac_long": 51792398197835, 262 | "temperature_control": "absolute", 263 | "is_online": true, 264 | "last_seen_delta": 0, 265 | "zone_hwid": 718041192, 266 | "max_speed": 6, 267 | "serial_number": "", 268 | "order": 1, 269 | "data": { 270 | "status": "application", 271 | "is_on": true, 272 | "data_valid": true, 273 | "heater_installed": true, 274 | "heater_enabled": true, 275 | "speed": 1, 276 | "speed_m3h": 30, 277 | "speed_max_set": 6, 278 | "speed_min_set": 0, 279 | "speed_limit": 6, 280 | "t_in": 6, 281 | "t_set": 22, 282 | "t_out": 22, 283 | "gate": 2, 284 | "run_seconds": 16358619, 285 | "filter_time_seconds": 19050215, 286 | "rc_controlled": false, 287 | "filter_need_replace": false, 288 | "signal_level": 154, 289 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 290 | "measurement_time": 1569745525, 291 | "errors": { 292 | "code": "0x00000000", 293 | "list": [] 294 | } 295 | }, 296 | "firmware": "003B", 297 | "hardware": "0001", 298 | "t_max": 30, 299 | "t_min": 0, 300 | "creation_time": 1567999850, 301 | "update_time": 1569382596 302 | } 303 | ], 304 | "order": 2, 305 | "creation_time_iso": "2019-08-29T08:10:28.2951497Z", 306 | "creation_time": 1567066228, 307 | "update_time_iso": "2019-08-29T08:10:28.2951497Z", 308 | "update_time": 1567066228 309 | }, 310 | { 311 | "guid": "98e2c839-781c-4a45-9ba2-92ce6ce2d181", 312 | "name": "Kitchen", 313 | "type": "unkown", 314 | "color": "33cc99", 315 | "is_virtual": false, 316 | "mode": { 317 | "current": "manual", 318 | "auto_set": { 319 | "co2": 800, 320 | "temperature": 0, 321 | "humidity": 0, 322 | "noise": 0, 323 | "pm25": 0, 324 | "pm10": 0 325 | } 326 | }, 327 | "schedule": { 328 | "is_schedule_sync": true, 329 | "is_active": false, 330 | "is_mode_sync": false, 331 | "current_preset": {}, 332 | "next_preset_starts_at": 0, 333 | "next_starts_iso": "1970-01-01T00:00:00.0000000Z" 334 | }, 335 | "sensors_average": [ 336 | { 337 | "data_type": "co2th", 338 | "have_sensors": [], 339 | "data": { 340 | "co2": "NaN", 341 | "temperature": "NaN", 342 | "humidity": "NaN", 343 | "pm25": "NaN", 344 | "pm10": "NaN", 345 | "radon": 0, 346 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 347 | "measurement_time": 1569745525 348 | } 349 | } 350 | ], 351 | "hw_id": 718041193, 352 | "devices": [ 353 | { 354 | "guid": "46a67439-a705-4c1e-8114-81062ddf4c54", 355 | "name": "Вентилятор", 356 | "type": "tionO2Rf", 357 | "subtype_d": 0, 358 | "control_type": "rf", 359 | "mac": "48:18:1B:0A:9C:F7", 360 | "mac_long": 272249556506696, 361 | "temperature_control": "absolute", 362 | "is_online": true, 363 | "last_seen_delta": 0, 364 | "zone_hwid": 718041193, 365 | "max_speed": 4, 366 | "serial_number": "", 367 | "order": 0, 368 | "data": { 369 | "status": "application", 370 | "is_on": false, 371 | "data_valid": true, 372 | "heater_installed": true, 373 | "heater_enabled": true, 374 | "speed": 1, 375 | "speed_m3h": 0, 376 | "speed_max_set": 4, 377 | "speed_min_set": 0, 378 | "speed_limit": 4, 379 | "t_in": 23, 380 | "t_set": 25, 381 | "t_out": 23, 382 | "run_seconds": 1410660, 383 | "filter_time_seconds": 30212900, 384 | "rc_controlled": false, 385 | "filter_need_replace": false, 386 | "signal_level": 136, 387 | "measurement_time_iso": "2019-09-29T08:25:25.0000000", 388 | "measurement_time": 1569745525, 389 | "errors": { 390 | "code": "0x00000000", 391 | "list": [] 392 | } 393 | }, 394 | "firmware": "018F:130C", 395 | "hardware": "0001:6108", 396 | "t_max": 25, 397 | "t_min": -20, 398 | "creation_time": 1567999903, 399 | "update_time": 1569382615 400 | }, 401 | { 402 | "guid": "3b71bbe5-12c7-40b8-b220-9811e92e1f6b", 403 | "name": "Датчики", 404 | "type": "co2Plus", 405 | "subtype_d": 0, 406 | "control_type": "rf", 407 | "mac": "68:41:2B:36:24:55", 408 | "mac_long": 85804432531811, 409 | "is_online": true, 410 | "last_seen_delta": 0, 411 | "zone_hwid": 718041193, 412 | "serial_number": "CO2 DefaultSN", 413 | "order": 0, 414 | "data": { 415 | "status": "application", 416 | "co2": 760.0, 417 | "temperature": 26.4, 418 | "humidity": 29.8, 419 | "signal_level": 114, 420 | "backlight": 1, 421 | "measurement_time_iso": "2020-04-04T11:14:45.0000000", 422 | "measurement_time": 1585998885 423 | }, 424 | "firmware": "0105", 425 | "hardware": "0001", 426 | "creation_time": 1560682543, 427 | "update_time": 1581488034 428 | } 429 | ], 430 | "order": 3, 431 | "creation_time_iso": "2019-08-29T08:16:17.7658686Z", 432 | "creation_time": 1567066577, 433 | "update_time_iso": "2019-08-29T08:16:17.7658686Z", 434 | "update_time": 1567066577 435 | } 436 | ], 437 | "creation_time_iso": "2019-08-29T07:58:03.5258853Z", 438 | "creation_time": 1567065483, 439 | "update_time_iso": "2019-08-29T07:58:03.5258853Z", 440 | "update_time": 1567065483 441 | } 442 | ] 443 | -------------------------------------------------------------------------------- /__mocks__/node-fetch.js: -------------------------------------------------------------------------------- 1 | const authTestData = require('./data/auth.json'); 2 | const locationTestData = require('./data/location.json'); 3 | 4 | const node_fetch = jest.genMockFromModule('node-fetch'); 5 | 6 | function auth(options) { 7 | return { 8 | ok: true, 9 | json: () => Promise.resolve(authTestData) 10 | }; 11 | } 12 | 13 | function location(options) { 14 | return { 15 | ok: true, 16 | json: () => Promise.resolve(locationTestData) 17 | }; 18 | } 19 | 20 | node_fetch.__initialize = () => { 21 | } 22 | 23 | node_fetch.default = (url, options) => { 24 | switch (url) { 25 | default: 26 | throw new Error('Unknown url'); 27 | case 'https://api2.magicair.tion.ru/idsrv/oauth2/token': 28 | return auth(options); 29 | case 'https://api2.magicair.tion.ru/location': 30 | return location(options); 31 | } 32 | } 33 | 34 | module.exports = node_fetch; 35 | -------------------------------------------------------------------------------- /config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginAlias": "Tion", 3 | "pluginType": "platform", 4 | "schema": { 5 | "type": "object", 6 | "properties": { 7 | "name": { 8 | "title": "Имя плагина", 9 | "description": "Необходимо для корректной работы Homebridge", 10 | "placeholder": "например, Tion", 11 | "type": "string", 12 | "default": "Tion", 13 | "minLength": 1, 14 | "required": true, 15 | "validationMessages": { 16 | "required": "Пожалуйста, укажите имя плагина" 17 | } 18 | }, 19 | "homeName": { 20 | "title": "Имя дома в MagicAir", 21 | "description": "Если у вас один дом в MagicAir, оставьте это поле пустым. Если у вас несколько домов, укажите имя дома в MagicAir, приборами в котором вы хотите управлять.", 22 | "placeholder": "например, Дом", 23 | "type": "string" 24 | }, 25 | "userName": { 26 | "title": "Email", 27 | "description": "Необходим для входа в систему MagicAir", 28 | "type": "string", 29 | "minLength": 1, 30 | "required": true, 31 | "validationMessages": { 32 | "required": "Пожалуйста, укажите email" 33 | } 34 | }, 35 | "password": { 36 | "title": "Пароль", 37 | "description": "Необходим для входа в систему MagicAir", 38 | "type": "string", 39 | "minLength": 1, 40 | "required": true, 41 | "validationMessages": { 42 | "required": "Пожалуйста, укажите пароль" 43 | } 44 | }, 45 | "co2Threshold": { 46 | "title": "Предельный уровень CO2, ppm", 47 | "description": "Уровень CO2, выше которого будет сигнализировать датчик CO2 в Homekit", 48 | "type": "integer", 49 | "default": 800, 50 | "minimum": 0, 51 | "maximum": 2500 52 | }, 53 | "apiRequestTimeout": { 54 | "title": "Таймаут запросов к серверу MagiAir, мс", 55 | "description": "Таймаут, по истечении которого запрос к серверу MagicAir принудительно завершается с ошибкой ", 56 | "type": "integer", 57 | "default": 1500, 58 | "minimum": 1000, 59 | "maximum": 30000 60 | } 61 | } 62 | }, 63 | "layout": [ 64 | { 65 | "type": "flex", 66 | "flex-flow": "row wrap", 67 | "items": [ 68 | "name", 69 | "homeName" 70 | ] 71 | }, 72 | { 73 | "type": "flex", 74 | "flex-flow": "row wrap", 75 | "items": [ 76 | "userName", 77 | "password" 78 | ] 79 | }, 80 | { 81 | "type": "fieldset", 82 | "expandable": true, 83 | "title": "Дополнительно", 84 | "items": ["co2Threshold", "apiRequestTimeout"] 85 | } 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /deploy/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf ./dist 4 | rm -f ./homebridge-tion-1.0.23.tgz 5 | npm run build 6 | npm pack 7 | scp ./homebridge-tion-1.0.23.tgz home@10.1.1.5:/home/home 8 | ssh home@10.1.1.5 'bash -s' < ./deploy/update.sh -------------------------------------------------------------------------------- /deploy/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ~/.profile 4 | export NVM_DIR="$HOME/.nvm" 5 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" 6 | 7 | rm -rf ~/.homebridge/experimental/node_modules/homebridge-tion/* 8 | tar -xzvf ~/homebridge-tion-1.0.23.tgz 9 | cp -rf ~/package/* ~/.homebridge/experimental/node_modules/homebridge-tion 10 | rm -rf ~/package 11 | rm -rf ~/homebridge-tion-1.0.23.tgz 12 | pm2 restart hb-exp -------------------------------------------------------------------------------- /docs/homebridge-tion-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/break-pointer/homebridge-tion/ff2bbbd4d3d718ff10b4b9ffcc06bb417a18a8b3/docs/homebridge-tion-page.png -------------------------------------------------------------------------------- /docs/homekit-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/break-pointer/homebridge-tion/ff2bbbd4d3d718ff10b4b9ffcc06bb417a18a8b3/docs/homekit-demo.png -------------------------------------------------------------------------------- /docs/homekit-demo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/break-pointer/homebridge-tion/ff2bbbd4d3d718ff10b4b9ffcc06bb417a18a8b3/docs/homekit-demo1.png -------------------------------------------------------------------------------- /docs/homekit-demo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/break-pointer/homebridge-tion/ff2bbbd4d3d718ff10b4b9ffcc06bb417a18a8b3/docs/homekit-demo2.png -------------------------------------------------------------------------------- /docs/homekit-demo3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/break-pointer/homebridge-tion/ff2bbbd4d3d718ff10b4b9ffcc06bb417a18a8b3/docs/homekit-demo3.png -------------------------------------------------------------------------------- /docs/homekit-demo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/break-pointer/homebridge-tion/ff2bbbd4d3d718ff10b4b9ffcc06bb417a18a8b3/docs/homekit-demo4.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-tion", 3 | "version": "1.0.23", 4 | "description": "Homebridge plugin to control Tion breezers", 5 | "main": "./dist/index.js", 6 | "config": { 7 | "ghooks": { 8 | "pre-commit": "npm run type-check && npm run lint && npm run test" 9 | } 10 | }, 11 | "lint-staged": { 12 | "src/**/*.ts": [ 13 | "prettier --write --config ./.prettierrc", 14 | "tslint --fix --project ./tsconfig.json" 15 | ] 16 | }, 17 | "scripts": { 18 | "dev": "ttsc -w", 19 | "type-check": "ttsc --skipLibCheck --noEmit", 20 | "lint": "tslint 'src/**/*.ts' --project ./tsconfig.json", 21 | "lint-fix": "tslint 'src/**/*.ts' --project ./tsconfig.json --fix", 22 | "prettier": "prettier --write --config ./.prettierrc src/**/*.ts", 23 | "test": "jest", 24 | "build": "ttsc", 25 | "preversion": "npm run type-check && npm run lint && npm run test && npm run build", 26 | "postversion": "git push && git push --tags" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/break-pointer/homebridge-tion.git" 31 | }, 32 | "keywords": [ 33 | "homebridge-plugin", 34 | "tion", 35 | "o2", 36 | "3s", 37 | "breezer", 38 | "air", 39 | "purifier", 40 | "co2", 41 | "sensor", 42 | "temperature", 43 | "humidity" 44 | ], 45 | "author": "Ilya Ruzakov (ilya.ruzz@gmail.com)", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/break-pointer/homebridge-tion/issues" 49 | }, 50 | "homepage": "https://github.com/break-pointer/homebridge-tion#readme", 51 | "engines": { 52 | "node": ">=8.10.0", 53 | "homebridge": ">=0.4.45" 54 | }, 55 | "jest": { 56 | "transform": { 57 | "^.+\\.tsx?$": "ts-jest" 58 | }, 59 | "testEnvironment": "node", 60 | "moduleDirectories": [ 61 | "node_modules", 62 | "src" 63 | ] 64 | }, 65 | "devDependencies": { 66 | "@types/jest": "^26.0.24", 67 | "@types/node": "^12.20.43", 68 | "@types/node-fetch": "^2.5.12", 69 | "ghooks": "^2.0.4", 70 | "jest": "^26.6.3", 71 | "lint-staged": "^10.5.4", 72 | "prettier": "^2.5.1", 73 | "ts-jest": "^26.5.6", 74 | "ts-node": "^8.10.2", 75 | "ts-transformer-imports": "^0.4.3", 76 | "tslint": "^5.20.1", 77 | "tslint-config-prettier": "^1.18.0", 78 | "tslint-plugin-prettier": "^2.3.0", 79 | "ttypescript": "^1.5.13", 80 | "typescript": "^4.5.5" 81 | }, 82 | "dependencies": { 83 | "node-fetch": "^2.6.7" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/accessories_factory.ts: -------------------------------------------------------------------------------- 1 | import {ILog, IHomebridgeAccessory, UuidGen} from './homebridge/framework'; 2 | 3 | import {TionDeviceBase} from './tion/devices/base'; 4 | import {TionMagicAirStation} from './tion/devices/station'; 5 | import {TionBreezer} from './tion/devices/breezer'; 6 | import {TionCO2Plus} from './tion/devices/co2plus'; 7 | 8 | export interface IAccessoriesFactory { 9 | createAccessories(device: TionDeviceBase): IHomebridgeAccessory[]; 10 | } 11 | 12 | export class AccessoriesFactory implements IAccessoriesFactory { 13 | private readonly log: ILog; 14 | private readonly serviceRegistry: any; 15 | private readonly characteristicRegistry: any; 16 | private readonly accessoryClass: any; 17 | private readonly generateUuid: UuidGen; 18 | 19 | constructor( 20 | log: ILog, 21 | serviceRegistry: any, 22 | characteristicRegistry: any, 23 | accessoryClass: any, 24 | generateUuid: UuidGen 25 | ) { 26 | this.log = log; 27 | this.serviceRegistry = serviceRegistry; 28 | this.characteristicRegistry = characteristicRegistry; 29 | this.accessoryClass = accessoryClass; 30 | this.generateUuid = generateUuid; 31 | } 32 | 33 | public createAccessories(device: TionDeviceBase): IHomebridgeAccessory[] { 34 | if (device instanceof TionMagicAirStation) { 35 | return this.createMagicAirStationAccessories(device); 36 | } else if (device instanceof TionBreezer) { 37 | return this.createBreezerAccessories(device); 38 | } else if (device instanceof TionCO2Plus) { 39 | return this.createCO2PlusAccessories(device); 40 | } else { 41 | throw new Error(`Unsupported device type ${device.constructor.name}`); 42 | } 43 | } 44 | 45 | private createMagicAirStationAccessories(device: TionMagicAirStation): IHomebridgeAccessory[] { 46 | const ret: IHomebridgeAccessory[] = []; 47 | const Accessory = this.accessoryClass; 48 | 49 | const accessory: IHomebridgeAccessory = new Accessory('MagicAir Station', this.generateUuid(device.id)); 50 | accessory.context = { 51 | id: device.id, 52 | }; 53 | 54 | accessory.on('identify', (paired, callback) => { 55 | this.log.info(`Identify ${device.id}`); 56 | 57 | callback(); 58 | }); 59 | 60 | accessory 61 | .getService(this.serviceRegistry.AccessoryInformation) 62 | .setCharacteristic(this.characteristicRegistry.Manufacturer, 'Tion') 63 | .setCharacteristic(this.characteristicRegistry.Model, device.modelName) 64 | .setCharacteristic(this.characteristicRegistry.SerialNumber, device.mac) 65 | .setCharacteristic(this.characteristicRegistry.FirmwareRevision, device.firmwareRevision) 66 | .setCharacteristic(this.characteristicRegistry.HardwareRevision, device.hardwareRevision); 67 | 68 | accessory 69 | .addService(this.serviceRegistry.CarbonDioxideSensor, 'CO2') 70 | .setCharacteristic(this.characteristicRegistry.CarbonDioxideDetected, 0) 71 | .setCharacteristic(this.characteristicRegistry.CarbonDioxideLevel, 0); 72 | 73 | accessory 74 | .addService(this.serviceRegistry.TemperatureSensor, 'Температура') 75 | .setCharacteristic(this.characteristicRegistry.CurrentTemperature, 0); 76 | 77 | accessory 78 | .addService(this.serviceRegistry.HumiditySensor, 'Влажность') 79 | .setCharacteristic(this.characteristicRegistry.CurrentRelativeHumidity, 0); 80 | 81 | accessory 82 | .addService(this.serviceRegistry.Switch, 'Подсветка') 83 | .setCharacteristic(this.characteristicRegistry.On, false); 84 | 85 | ret.push(accessory); 86 | 87 | return ret; 88 | } 89 | 90 | private createBreezerAccessories(device: TionBreezer): IHomebridgeAccessory[] { 91 | const ret: IHomebridgeAccessory[] = []; 92 | const Accessory = this.accessoryClass; 93 | 94 | const breezerAccessory: IHomebridgeAccessory = new Accessory(device.name, this.generateUuid(device.id)); 95 | breezerAccessory.context = { 96 | id: device.id, 97 | }; 98 | 99 | breezerAccessory.on('identify', (paired, callback) => { 100 | this.log.info(`Identify ${device.id}`); 101 | 102 | callback(); 103 | }); 104 | 105 | breezerAccessory 106 | .getService(this.serviceRegistry.AccessoryInformation) 107 | .setCharacteristic(this.characteristicRegistry.Manufacturer, 'Tion') 108 | .setCharacteristic(this.characteristicRegistry.Model, device.modelName) 109 | .setCharacteristic(this.characteristicRegistry.SerialNumber, device.mac) 110 | .setCharacteristic(this.characteristicRegistry.FirmwareRevision, device.firmwareRevision) 111 | .setCharacteristic(this.characteristicRegistry.HardwareRevision, device.hardwareRevision); 112 | 113 | const airPurifier = breezerAccessory 114 | .addService(this.serviceRegistry.AirPurifier, 'Приток') 115 | .setCharacteristic(this.characteristicRegistry.Active, 0) 116 | .setCharacteristic(this.characteristicRegistry.CurrentAirPurifierState, 0) 117 | .setCharacteristic(this.characteristicRegistry.TargetAirPurifierState, 1) 118 | .setCharacteristic(this.characteristicRegistry.RotationSpeed, 1); 119 | 120 | const filter = breezerAccessory 121 | .addService(this.serviceRegistry.FilterMaintenance, 'Фильтр') 122 | .setCharacteristic(this.characteristicRegistry.FilterChangeIndication, 0) 123 | .setCharacteristic(this.characteristicRegistry.FilterLifeLevel, 0); 124 | 125 | airPurifier.addLinkedService(filter); 126 | 127 | if (device.isAirIntakeInstalled) { 128 | breezerAccessory 129 | .addService(this.serviceRegistry.Switch, 'Рециркуляция') 130 | .setCharacteristic(this.characteristicRegistry.On, false); 131 | } 132 | 133 | if (device.isHeaterInstalled) { 134 | breezerAccessory 135 | .addService(this.serviceRegistry.HeaterCooler, 'Нагрев') 136 | .setCharacteristic(this.characteristicRegistry.Active, 0) 137 | .setCharacteristic(this.characteristicRegistry.CurrentHeaterCoolerState, 0) 138 | .setCharacteristic(this.characteristicRegistry.TargetHeaterCoolerState, 1) 139 | .setCharacteristic(this.characteristicRegistry.CurrentTemperature, 0) 140 | .setCharacteristic(this.characteristicRegistry.HeatingThresholdTemperature, 0); 141 | } 142 | 143 | ret.push(breezerAccessory); 144 | 145 | const tempSensorAccessory: IHomebridgeAccessory = new Accessory( 146 | device.name, 147 | this.generateUuid(`${device.id}:outside_temperature`) 148 | ); 149 | tempSensorAccessory.context = { 150 | id: device.id, 151 | }; 152 | 153 | tempSensorAccessory 154 | .getService(this.serviceRegistry.AccessoryInformation) 155 | .setCharacteristic(this.characteristicRegistry.Manufacturer, 'Tion') 156 | .setCharacteristic(this.characteristicRegistry.Model, device.modelName) 157 | .setCharacteristic(this.characteristicRegistry.SerialNumber, device.mac) 158 | .setCharacteristic(this.characteristicRegistry.FirmwareRevision, device.firmwareRevision) 159 | .setCharacteristic(this.characteristicRegistry.HardwareRevision, device.hardwareRevision); 160 | 161 | tempSensorAccessory 162 | .addService(this.serviceRegistry.TemperatureSensor, 'Температура уличного воздуха') 163 | .setCharacteristic(this.characteristicRegistry.StatusActive, 0) 164 | .setCharacteristic(this.characteristicRegistry.CurrentTemperature, 0); 165 | 166 | ret.push(tempSensorAccessory); 167 | 168 | return ret; 169 | } 170 | 171 | private createCO2PlusAccessories(device: TionCO2Plus): IHomebridgeAccessory[] { 172 | const ret: IHomebridgeAccessory[] = []; 173 | const Accessory = this.accessoryClass; 174 | 175 | const accessory: IHomebridgeAccessory = new Accessory(device.name, this.generateUuid(device.id)); 176 | accessory.context = { 177 | id: device.id, 178 | }; 179 | 180 | accessory.on('identify', (paired, callback) => { 181 | this.log.info(`Identify ${device.id}`); 182 | 183 | callback(); 184 | }); 185 | 186 | accessory 187 | .getService(this.serviceRegistry.AccessoryInformation) 188 | .setCharacteristic(this.characteristicRegistry.Manufacturer, 'Tion') 189 | .setCharacteristic(this.characteristicRegistry.Model, device.modelName) 190 | .setCharacteristic(this.characteristicRegistry.SerialNumber, device.mac) 191 | .setCharacteristic(this.characteristicRegistry.FirmwareRevision, device.firmwareRevision) 192 | .setCharacteristic(this.characteristicRegistry.HardwareRevision, device.hardwareRevision); 193 | 194 | accessory 195 | .addService(this.serviceRegistry.CarbonDioxideSensor, 'CO2') 196 | .setCharacteristic(this.characteristicRegistry.CarbonDioxideDetected, 0) 197 | .setCharacteristic(this.characteristicRegistry.CarbonDioxideLevel, 0); 198 | 199 | accessory 200 | .addService(this.serviceRegistry.TemperatureSensor, 'Температура') 201 | .setCharacteristic(this.characteristicRegistry.CurrentTemperature, 0); 202 | 203 | accessory 204 | .addService(this.serviceRegistry.HumiditySensor, 'Влажность') 205 | .setCharacteristic(this.characteristicRegistry.CurrentRelativeHumidity, 0); 206 | 207 | ret.push(accessory); 208 | 209 | return ret; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/homebridge/framework.ts: -------------------------------------------------------------------------------- 1 | export type UuidGen = (seed: string) => string; 2 | 3 | export interface IHomebridge { 4 | hap: { 5 | Service: any; 6 | Characteristic: any; 7 | uuid: any; 8 | }; 9 | user: any; 10 | platformAccessory: any; 11 | registerPlatform: (identifier: string, name: string, platform: typeof HomebridgePlatform, dynamic: boolean) => void; 12 | } 13 | 14 | export interface IHomebridgeAccessory { 15 | UUID: string; 16 | on: (event: string, eventHandler: any) => any; 17 | addCharacteristic: (characteristic: any) => any; 18 | getCharacteristic: (characteristic: any) => any; 19 | setCharacteristic: (characteristic: any, value: any) => any; 20 | updateCharacteristic: (characteristic: any, value: any) => any; 21 | addService: (service: any, name: string) => any; 22 | getService: (service: any) => any; 23 | setPower: (on: boolean) => any; 24 | reachable: boolean; 25 | displayName: string; 26 | context: { 27 | id: string; 28 | }; 29 | } 30 | 31 | type LogFunction = (...messages: any) => void; 32 | export interface ILog { 33 | readonly debug: LogFunction; 34 | readonly info: LogFunction; 35 | readonly warn: LogFunction; 36 | readonly error: LogFunction; 37 | readonly log: LogFunction; 38 | (...messages: any): void; 39 | } 40 | 41 | export interface IHomebridgeApi { 42 | registerPlatformAccessories: (identifier: string, name: string, accessories: any[]) => void; 43 | unregisterPlatformAccessories: (identifier: string, name: string, accessories: any[]) => void; 44 | on: (eventName: string, callback: () => void) => void; 45 | } 46 | 47 | export interface IHomebridgePlatformConfig {} 48 | 49 | export abstract class HomebridgePlatform { 50 | public abstract configureAccessory: (accessory: IHomebridgeAccessory) => void; 51 | 52 | protected readonly log: ILog; 53 | protected readonly config: IHomebridgePlatformConfig; 54 | protected readonly hbApi: IHomebridgeApi; 55 | 56 | constructor(log: ILog, config: IHomebridgePlatformConfig, hbApi: IHomebridgeApi) { 57 | this.log = log; 58 | this.config = config; 59 | this.hbApi = hbApi; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | HomebridgePlatform, 3 | IHomebridgeAccessory, 4 | ILog, 5 | IHomebridgeApi, 6 | IHomebridge, 7 | UuidGen, 8 | } from 'homebridge/framework'; 9 | import {TionPlatform} from 'platform'; 10 | import {ITionPlatformConfig, PluginName, PlatformName, sanitize, validate} from 'platform_config'; 11 | import {TionFilesystemAuthStorage, TionAuthApi} from 'tion/auth'; 12 | import {TionApi} from 'tion/api'; 13 | import {TionDevicesFactory} from 'tion/devices/factory'; 14 | import {AccessoriesFactory} from 'accessories_factory'; 15 | 16 | let GenerateUuid: UuidGen; 17 | let Service: any; 18 | let Characteristic: any; 19 | let Accessory: any; 20 | let User: any; 21 | 22 | class TionPlatformWrapper extends HomebridgePlatform { 23 | private readonly instance: TionPlatform; 24 | 25 | constructor(log: ILog, config: ITionPlatformConfig, hbApi: IHomebridgeApi) { 26 | super(log, config, hbApi); 27 | 28 | if (!log) { 29 | throw new Error('Tion: log service not found. Probably incompatible Homebridge version'); 30 | } 31 | if (!config || typeof config !== 'object') { 32 | log.error('config not set, stopping platform'); 33 | return; 34 | } 35 | if (!hbApi) { 36 | log.error('api service not found, probably incompatible Homebridge version, stopping platform'); 37 | return; 38 | } 39 | 40 | if (!validate(log, sanitize(log, config))) { 41 | this.log.error('config invalid, stopping platform'); 42 | return; 43 | } 44 | 45 | const authStorage = new TionFilesystemAuthStorage(log, User.persistPath()); 46 | const authApi = new TionAuthApi(log, config, authStorage); 47 | const api = new TionApi(log, config, authApi); 48 | const devicesFactory = new TionDevicesFactory(log, config, api, Service, Characteristic); 49 | const accessoriesFactory = new AccessoriesFactory(log, Service, Characteristic, Accessory, GenerateUuid); 50 | 51 | this.instance = new TionPlatform(log, hbApi, api, devicesFactory, accessoriesFactory); 52 | } 53 | 54 | public configureAccessory = (accessory: IHomebridgeAccessory) => { 55 | return this.instance && this.instance.loadCachedAccessory(accessory); 56 | }; 57 | } 58 | 59 | export default (homebridge: IHomebridge): void => { 60 | GenerateUuid = homebridge.hap.uuid.generate; 61 | Service = homebridge.hap.Service; 62 | Characteristic = homebridge.hap.Characteristic; 63 | Accessory = homebridge.platformAccessory; 64 | User = homebridge.user; 65 | 66 | homebridge.registerPlatform(PluginName, PlatformName, TionPlatformWrapper, true); 67 | }; 68 | -------------------------------------------------------------------------------- /src/platform.ts: -------------------------------------------------------------------------------- 1 | import {IHomebridgeAccessory, IHomebridgeApi, ILog} from './homebridge/framework'; 2 | import {ITionApi} from './tion/api'; 3 | import {TionDeviceBase} from './tion/devices/base'; 4 | import {PlatformName, PluginName} from './platform_config'; 5 | import {ITionDevicesFactory} from './tion/devices/factory'; 6 | import {IAccessoriesFactory} from './accessories_factory'; 7 | import {ILocation} from 'tion/state'; 8 | 9 | export class TionPlatform { 10 | private readonly log: ILog; 11 | private readonly hbApi: IHomebridgeApi; 12 | private readonly tionApi: ITionApi; 13 | private readonly tionDevicesFactory: ITionDevicesFactory; 14 | private readonly accessoriesFactory: IAccessoriesFactory; 15 | 16 | private tionDevices: TionDeviceBase[]; 17 | private hbAccessories: IHomebridgeAccessory[]; 18 | private pollInterval: NodeJS.Timeout; 19 | 20 | constructor( 21 | log: ILog, 22 | hbApi: IHomebridgeApi, 23 | api: ITionApi, 24 | devicesFactory: ITionDevicesFactory, 25 | accessoriesFactory: IAccessoriesFactory 26 | ) { 27 | this.log = log; 28 | this.hbApi = hbApi; 29 | this.tionApi = api; 30 | this.tionDevicesFactory = devicesFactory; 31 | this.accessoriesFactory = accessoriesFactory; 32 | 33 | this.tionDevices = []; 34 | this.hbAccessories = []; 35 | 36 | hbApi.on('didFinishLaunching', this.init); 37 | hbApi.on('shutdown', this.shutdown); 38 | } 39 | 40 | public loadCachedAccessory = (accessory: IHomebridgeAccessory) => { 41 | this.log.debug(`Received accessory for ${accessory.context.id}`); 42 | 43 | if (!this.hbAccessories.find(a => a.UUID === accessory.UUID)) { 44 | this.hbAccessories.push(accessory); 45 | } 46 | }; 47 | 48 | private init = async () => { 49 | this.log.debug('Initializing'); 50 | try { 51 | await this.tionApi.init(); 52 | const location = await this.tionApi.getSystemState(); 53 | this.tionDevices = this.tionDevicesFactory.createDevices([], location); 54 | this.mergeAccessories(); 55 | 56 | this.pollInterval = setInterval(this.poll, 60000); 57 | } catch (err) { 58 | this.log.error('Initialization error'); 59 | this.log.error(err); 60 | } 61 | }; 62 | 63 | private shutdown = async () => { 64 | this.log.debug('Shutting down'); 65 | clearInterval(this.pollInterval); 66 | }; 67 | 68 | private poll = async () => { 69 | const location = await this.tionApi.getSystemState(); 70 | this.tionDevices = this.tionDevicesFactory.createDevices(this.tionDevices, location); 71 | this.mergeAccessories(); 72 | this.updateState(location); 73 | }; 74 | 75 | private mergeAccessories(): void { 76 | this.log.debug(`Merging ${this.tionDevices.length} devices and ${this.hbAccessories.length} accessories`); 77 | // create new devices 78 | this.tionDevices.forEach(device => { 79 | const registeredAccessories = this.hbAccessories.filter(a => a.context.id === device.id); 80 | if (registeredAccessories.length) { 81 | registeredAccessories.forEach(a => device.addEventHandlers(a)); 82 | } else { 83 | const newAccessories = this.accessoriesFactory.createAccessories(device); 84 | this.hbAccessories.push.apply(this.hbAccessories, newAccessories); 85 | newAccessories.forEach(a => device.addEventHandlers(a)); 86 | 87 | this.hbApi.registerPlatformAccessories(PluginName, PlatformName, newAccessories); 88 | } 89 | }); 90 | 91 | this.log.debug(`Got ${this.hbAccessories.length} accessories after creating new`); 92 | 93 | // remove outdated accessories 94 | const byId = this.hbAccessories.reduce((all, cur) => { 95 | if (!all[cur.context.id]) { 96 | all[cur.context.id] = []; 97 | } 98 | all[cur.context.id].push(cur); 99 | return all; 100 | }, {}); 101 | 102 | const toRemove: IHomebridgeAccessory[] = []; 103 | Object.keys(byId).forEach(id => { 104 | const device = this.tionDevices.find(d => d.id === id); 105 | if (!device) { 106 | toRemove.push.apply(toRemove, byId[id]); 107 | } 108 | }); 109 | 110 | if (toRemove.length) { 111 | this.hbApi.unregisterPlatformAccessories(PluginName, PlatformName, toRemove); 112 | this.hbAccessories = this.hbAccessories.filter(a => !toRemove.find(b => b.context.id === a.context.id)); 113 | } 114 | 115 | this.log.debug( 116 | `Got ${this.hbAccessories.length} accessories after removing ${toRemove.length} outdated accessories` 117 | ); 118 | } 119 | 120 | private updateState(location: ILocation) { 121 | this.tionDevices.forEach(device => device.updateState(location)); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/platform_config.ts: -------------------------------------------------------------------------------- 1 | import {IHomebridgePlatformConfig, ILog} from 'homebridge/framework'; 2 | 3 | export const PluginName = 'homebridge-tion'; 4 | export const PlatformName = 'Tion'; 5 | 6 | export interface ITionPlatformConfig extends IHomebridgePlatformConfig { 7 | name: string; 8 | stationName?: string; // deprecated since 1.0.4 9 | homeName: string; 10 | userName: string; 11 | password: string; 12 | co2Threshold: number; 13 | apiRequestTimeout: number; 14 | percentSpeed: boolean; 15 | getStateDebounce: number; 16 | } 17 | 18 | export function validate(log: ILog, config: ITionPlatformConfig): boolean { 19 | if (!config.userName || !config.password) { 20 | log.error('config has invalid credentials'); 21 | return false; 22 | } 23 | 24 | return true; 25 | } 26 | 27 | export function sanitize(log: ILog, config: ITionPlatformConfig): ITionPlatformConfig { 28 | if (!config.name || typeof config.name !== 'string') { 29 | log.warn(`config.name has incompatible value, setting "Tion"`); 30 | Object.assign(config, {name: 'Tion'}); 31 | } 32 | if (config.stationName) { 33 | if (!config.homeName) { 34 | config.homeName = config.stationName; 35 | } 36 | 37 | delete config.stationName; 38 | } 39 | if ('homeName' in config && typeof config.homeName !== 'string') { 40 | log.warn(`config.homeName has incompatible value, removing`); 41 | // @ts-expect-error The operand of a 'delete' operator must be optional. 42 | delete config.homeName; 43 | } 44 | if ('userName' in config && typeof config.userName !== 'string') { 45 | log.warn(`config.userName has incompatible value, removing`); 46 | // @ts-expect-error The operand of a 'delete' operator must be optional. 47 | delete config.userName; 48 | } 49 | if ('password' in config && typeof config.password !== 'string') { 50 | log.warn(`config.password has incompatible value, removing`); 51 | // @ts-expect-error The operand of a 'delete' operator must be optional. 52 | delete config.password; 53 | } 54 | if ('co2Threshold' in config) { 55 | if (!Number.isInteger(config.co2Threshold) || config.co2Threshold < 0 || config.co2Threshold > 2500) { 56 | log.warn(`config.co2Threshold has incompatible value, setting 800`); 57 | Object.assign(config, {co2Threshold: 800}); 58 | } 59 | } else { 60 | Object.assign(config, {co2Threshold: 800}); 61 | } 62 | if ('apiRequestTimeout' in config) { 63 | if ( 64 | !Number.isInteger(config.apiRequestTimeout) || 65 | config.apiRequestTimeout < 1000 || 66 | config.apiRequestTimeout > 30000 67 | ) { 68 | log.warn(`config.apiRequestTimeout has incompatible value, setting 1500`); 69 | Object.assign(config, {apiRequestTimeout: 1500}); 70 | } 71 | } else { 72 | Object.assign(config, {apiRequestTimeout: 1500}); 73 | } 74 | if ('percentSpeed' in config) { 75 | if ( 76 | config.percentSpeed !== false && 77 | config.percentSpeed !== true 78 | ) { 79 | log.warn(`config.percentSpeed has incompatible value, setting false`); 80 | Object.assign(config, {percentSpeed: false}); 81 | } 82 | } else { 83 | Object.assign(config, {percentSpeed: false}); 84 | } 85 | if ('getStateDebounce' in config) { 86 | if ( 87 | !Number.isInteger(config.getStateDebounce) || 88 | config.getStateDebounce < 1000 || 89 | config.getStateDebounce > 30000 90 | ) { 91 | log.warn(`config.getStateDebounce has incompatible value, setting 5000`); 92 | Object.assign(config, {getStateDebounce: 5000}); 93 | } 94 | } else { 95 | Object.assign(config, {getStateDebounce: 5000}); 96 | } 97 | return config; 98 | } 99 | -------------------------------------------------------------------------------- /src/tion/api.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import {ILog} from 'homebridge/framework'; 4 | 5 | import {ITionAuthApi} from './auth'; 6 | import {ILocation} from './state'; 7 | import {ITionPlatformConfig} from 'platform_config'; 8 | import { 9 | CommandStatus, 10 | CommandTarget, 11 | CommandType, 12 | IBreezerCommand, 13 | ICommandResult, 14 | IStationCommand, 15 | IZoneCommand, 16 | } from './command'; 17 | 18 | enum AuthState { 19 | NoToken = 'no_token', 20 | HasToken = 'has_token', 21 | TokenExpired = 'token_expired', 22 | } 23 | 24 | export interface ITionApi { 25 | init(): Promise; 26 | getSystemState(): Promise; 27 | execDeviceCommand( 28 | commandType: CommandType, 29 | deviceId: string, 30 | payload: IBreezerCommand | IStationCommand 31 | ): Promise; 32 | execZoneCommand(zoneId: string, payload: IZoneCommand): Promise; 33 | } 34 | 35 | export class TionApi implements ITionApi { 36 | private static ApiBasePath = 'https://api2.magicair.tion.ru'; 37 | 38 | private readonly log: ILog; 39 | 40 | private readonly authApi: ITionAuthApi; 41 | private readonly config: ITionPlatformConfig; 42 | 43 | private stateRequest?: Promise; 44 | private stateResult?: ILocation[]; 45 | 46 | private lastStateRequestTimestamp: number; 47 | private lastCommandTimestamp: number; 48 | 49 | constructor(log: ILog, config: ITionPlatformConfig, authApi: ITionAuthApi) { 50 | this.log = log; 51 | this.config = config; 52 | this.authApi = authApi; 53 | this.lastStateRequestTimestamp = 0; 54 | this.lastCommandTimestamp = 0; 55 | } 56 | 57 | public async init(): Promise { 58 | await this.authApi.init(); 59 | } 60 | 61 | public async getSystemState(): Promise { 62 | // force last received state if it's not older than config.getStateDebounce 63 | // and there were no commands since it was received 64 | const now = Date.now(); 65 | if ( 66 | this.stateResult && 67 | now < this.lastStateRequestTimestamp + this.config.getStateDebounce && 68 | this.lastCommandTimestamp < this.lastStateRequestTimestamp 69 | ) { 70 | return this.parseStateResult(this.stateResult); 71 | } 72 | 73 | // debounce sequential state retrieval, while HTTP request is running 74 | let firstRequest = false; 75 | if (!this.stateRequest) { 76 | this.log.debug('Loading system state'); 77 | this.lastStateRequestTimestamp = Date.now(); 78 | this.stateRequest = this.apiRequest('get', '/location', { 79 | timeout: this.config.apiRequestTimeout, 80 | }); 81 | firstRequest = true; 82 | } 83 | let stateResult: ILocation[] | undefined; 84 | try { 85 | stateResult = await this.stateRequest; 86 | } finally { 87 | if (firstRequest) { 88 | this.stateRequest = undefined; 89 | this.stateResult = stateResult; 90 | } 91 | } 92 | return this.parseStateResult(stateResult); 93 | } 94 | 95 | public async execDeviceCommand( 96 | commandType: CommandType, 97 | deviceId: string, 98 | payload: IBreezerCommand | IStationCommand 99 | ): Promise { 100 | return this.execCommandInternal(commandType, CommandTarget.Device, deviceId, payload); 101 | } 102 | 103 | public async execZoneCommand(zoneId: string, payload: IZoneCommand): Promise { 104 | return this.execCommandInternal(CommandType.Mode, CommandTarget.Zone, zoneId, payload); 105 | } 106 | 107 | private async execCommandInternal( 108 | type: CommandType, 109 | target: CommandTarget, 110 | targetId: string, 111 | payload: IBreezerCommand | IStationCommand | IZoneCommand 112 | ): Promise { 113 | this.log.debug( 114 | `TionApi.execCommand(type = ${type}, target: ${target}, targetId = ${targetId}, payload = ${JSON.stringify( 115 | payload 116 | )})` 117 | ); 118 | this.lastCommandTimestamp = Date.now(); 119 | try { 120 | let result: ICommandResult = await this.apiRequest('post', `/${target}/${targetId}/${type}`, { 121 | body: payload, 122 | timeout: this.config.apiRequestTimeout, 123 | }); 124 | const commandId = result.task_id; 125 | this.log.debug( 126 | `TionApi.execCommand(objectId = ${targetId}, commandType: ${target}, result = ${JSON.stringify( 127 | result 128 | )})` 129 | ); 130 | let attempts = 0; 131 | while (result.status !== CommandStatus.Completed && attempts++ < 4) { 132 | switch (result.status) { 133 | default: 134 | this.log.error(`Unknown command status`); 135 | this.log.error(result); 136 | // noinspection ExceptionCaughtLocallyJS 137 | throw new Error('Status'); 138 | case CommandStatus.Delivered: 139 | case CommandStatus.Queued: 140 | await new Promise(resolve => setTimeout(resolve, attempts * 100)); 141 | result = await this.apiRequest('get', `/task/${commandId}`, { 142 | timeout: this.config.apiRequestTimeout, 143 | }); 144 | this.log.debug(`TionApi.execCommand(result = ${JSON.stringify(result)})`); 145 | break; 146 | } 147 | } 148 | return result; 149 | } catch (err) { 150 | this.log.error('Failed to execute command'); 151 | this.wrapError(err); 152 | this.log.error(err); 153 | throw err; 154 | } 155 | } 156 | 157 | private wrapError(err: any) { 158 | if (err.response) { 159 | err.response = undefined; 160 | } 161 | } 162 | 163 | private async apiRequest(method: 'get' | 'post', endpoint: string, options: any): Promise { 164 | let accessToken = this.authApi.getAccessToken(); 165 | let state: AuthState = accessToken ? AuthState.HasToken : AuthState.NoToken; 166 | let internalServerErrorAttempts = 0; 167 | while (true) { 168 | switch (state) { 169 | case AuthState.HasToken: 170 | this.log.debug('TionApi - has token'); 171 | try { 172 | const headers = Object.assign({}, options.headers || {}, { 173 | Authorization: `Bearer ${accessToken}`, 174 | }); 175 | if (options.body !== undefined) { 176 | headers['Content-Type'] = 'application/json'; 177 | options.body = JSON.stringify(options.body); 178 | } 179 | const result = await fetch(`${TionApi.ApiBasePath}${endpoint}`, { 180 | ...options, 181 | headers, 182 | method: method.toUpperCase(), 183 | }); 184 | 185 | if (result.ok) { 186 | return result.json(); 187 | } 188 | 189 | if (result.status === 401) { 190 | this.log.error('TionApi - token_expired: ', result.statusText); 191 | state = AuthState.TokenExpired; 192 | } else if (result.status === 500 && internalServerErrorAttempts++ < 2) { 193 | this.log.error( 194 | `TionApi - got internal server error, retrying attempt ${internalServerErrorAttempts}:`, 195 | result.statusText 196 | ); 197 | await new Promise(resolve => setTimeout(resolve, internalServerErrorAttempts * 500)); 198 | } else { 199 | let payload: Buffer | null = null; 200 | try { 201 | payload = await result.buffer(); 202 | } catch { 203 | // relax lint 204 | } 205 | // noinspection ExceptionCaughtLocallyJS 206 | throw new Error( 207 | `TionApi - error ${result.status} ${result.statusText} ${payload?.toString()}` 208 | ); 209 | } 210 | } catch (err) { 211 | if ( 212 | (err.code === 'ESOCKETTIMEDOUT' || err.type === 'request-timeout') && 213 | internalServerErrorAttempts++ < 2 214 | ) { 215 | this.log.error( 216 | `TionApi - got timeout, retrying attempt ${internalServerErrorAttempts}:`, 217 | err.message 218 | ); 219 | await new Promise(resolve => setTimeout(resolve, internalServerErrorAttempts * 500)); 220 | } else { 221 | this.wrapError(err); 222 | throw err; 223 | } 224 | } 225 | break; 226 | 227 | case AuthState.NoToken: 228 | this.log.debug('TionApi - no token'); 229 | 230 | internalServerErrorAttempts = 0; 231 | try { 232 | accessToken = await this.authApi.authenticateUsingPassword(); 233 | state = AuthState.HasToken; 234 | } catch (err) { 235 | this.log.error('TionApi - no_token:', err.message || err.statusCode); 236 | this.wrapError(err); 237 | throw err; 238 | } 239 | break; 240 | 241 | case AuthState.TokenExpired: 242 | this.log.debug('TionApi - token expired'); 243 | 244 | internalServerErrorAttempts = 0; 245 | try { 246 | accessToken = await this.authApi.authenticateUsingRefreshToken(); 247 | state = AuthState.HasToken; 248 | } catch (err) { 249 | this.log.error('TionApi - token_expired:', err.message || err.statusCode); 250 | if (err.statusCode >= 400 && err.statusCode < 500) { 251 | state = AuthState.NoToken; 252 | } else { 253 | this.wrapError(err); 254 | throw err; 255 | } 256 | } 257 | break; 258 | } 259 | } 260 | } 261 | 262 | private parseStateResult(stateResult: ILocation[]): ILocation { 263 | let ret: ILocation | undefined; 264 | if (this.config.homeName) { 265 | const lower = this.config.homeName.toLowerCase().trim(); 266 | const location = stateResult.find(loc => loc.name.toLowerCase().trim() === lower); 267 | if (location) { 268 | ret = location; 269 | } else { 270 | this.log.warn(`Location ${this.config.homeName} not found, using first suitable`); 271 | } 272 | } 273 | if (!ret) { 274 | ret = stateResult.find(loc => loc.zones.length) || stateResult[0]; 275 | } 276 | 277 | return ret!; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/tion/auth.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import url from 'url'; 4 | import fetch from 'node-fetch'; 5 | 6 | import {ILog} from 'homebridge/framework'; 7 | import {ITionPlatformConfig} from '../platform_config'; 8 | 9 | export interface ITionAuthData { 10 | access_token: string; 11 | expires_in: number; 12 | token_type: string; 13 | refresh_token: string; 14 | username: string; 15 | user_guid: string; 16 | client_id: string; 17 | '.issued': string; 18 | '.expires': string; 19 | } 20 | 21 | interface ITionAuthPayload { 22 | grant_type: 'password' | 'refresh_token'; 23 | username?: string; 24 | password?: string; 25 | refresh_token?: string; 26 | client_id: string; 27 | client_secret: string; 28 | } 29 | 30 | export interface ITionAuthApi { 31 | init(): Promise; 32 | getAccessToken(): string; 33 | authenticateUsingPassword(): Promise; 34 | authenticateUsingRefreshToken(): Promise; 35 | } 36 | 37 | export interface ITionAuthStorage { 38 | save(authData: ITionAuthData): Promise; 39 | load(): Promise; 40 | } 41 | 42 | export class AuthError extends Error { 43 | public statusCode: number; 44 | constructor(message: string, statusCode: number) { 45 | const trueProto = new.target.prototype; 46 | super(message); 47 | Object.setPrototypeOf(this, trueProto); 48 | 49 | this.statusCode = statusCode; 50 | } 51 | } 52 | 53 | export class TionAuthApi implements ITionAuthApi { 54 | private static oAuthUrl = 'https://api2.magicair.tion.ru/idsrv/oauth2/token'; 55 | private static clientId = 'a750d720-e146-47b0-b414-35e3b1dd7862'; 56 | private static clientSecret = 'DTT2jJnY3k2H2GyZ'; 57 | private static refreshClientId = '8b96527d-7632-4d56-bf75-3d1097e99d0e'; 58 | private static refreshClientSecret = 'qwerty'; 59 | 60 | private log: ILog; 61 | private config: ITionPlatformConfig; 62 | private myAuthData: ITionAuthData; 63 | private authStorage: ITionAuthStorage; 64 | 65 | constructor(log: ILog, config: ITionPlatformConfig, storage: ITionAuthStorage) { 66 | this.log = log; 67 | this.config = config; 68 | this.authStorage = storage; 69 | } 70 | 71 | public async init(): Promise { 72 | try { 73 | this.myAuthData = await this.authStorage.load(); 74 | } catch (err) { 75 | err = null; // relax lint 76 | } 77 | } 78 | 79 | public getAccessToken(): string { 80 | return this.myAuthData && this.myAuthData.access_token; 81 | } 82 | 83 | public async authenticateUsingPassword(): Promise { 84 | this.log.debug('TionAuthApi - authenticating using password'); 85 | await this._internalAuthenticate({ 86 | grant_type: 'password', 87 | username: this.config.userName, 88 | password: this.config.password, 89 | client_id: TionAuthApi.clientId, 90 | client_secret: TionAuthApi.clientSecret, 91 | }); 92 | await this.authStorage.save(this.myAuthData); 93 | 94 | return this.myAuthData && this.myAuthData.access_token; 95 | } 96 | 97 | public async authenticateUsingRefreshToken(): Promise { 98 | this.log.debug('TionAuthApi - authenticating using refresh token'); 99 | await this._internalAuthenticate({ 100 | grant_type: 'refresh_token', 101 | refresh_token: this.myAuthData.refresh_token, 102 | client_id: TionAuthApi.clientId, 103 | client_secret: TionAuthApi.clientSecret, 104 | }); 105 | await this.authStorage.save(this.myAuthData); 106 | 107 | return this.myAuthData.access_token; 108 | } 109 | 110 | private async _internalAuthenticate(params: ITionAuthPayload): Promise { 111 | try { 112 | const authResult = await fetch(TionAuthApi.oAuthUrl, { 113 | method: 'POST', 114 | body: new url.URLSearchParams(params as any), 115 | }); 116 | 117 | if (authResult.ok) { 118 | this.myAuthData = await authResult.json(); 119 | } else { 120 | throw new AuthError(authResult.statusText, authResult.status); 121 | } 122 | } catch (err) { 123 | this.log.error('TionAuthApi._internalAuthenticate: ', err.message); 124 | this.log.debug('TionAuthApi._internalAuthenticate: ', err); 125 | throw err; 126 | } 127 | } 128 | } 129 | 130 | export class TionFilesystemAuthStorage implements ITionAuthStorage { 131 | private basePath: string; 132 | private log: ILog; 133 | 134 | constructor(log: ILog, basePath: string) { 135 | this.log = log; 136 | this.basePath = basePath; 137 | if (!fs.existsSync(basePath)) { 138 | fs.mkdirSync(basePath, {recursive: true}); 139 | } 140 | } 141 | 142 | public async save(authData: ITionAuthData): Promise { 143 | fs.writeFileSync(this.getStatePath(), JSON.stringify(authData, null, 4), {encoding: 'utf8'}); 144 | this.log.debug('Auth tokens persisted'); 145 | } 146 | 147 | public async load(): Promise { 148 | try { 149 | const text = fs.readFileSync(this.getStatePath(), {encoding: 'utf8'}); 150 | const ret = JSON.parse(text); 151 | this.log.debug('Got persisted auth tokens'); 152 | return ret; 153 | } catch (err) { 154 | this.log.debug('Auth tokens not persisted'); 155 | throw err; 156 | } 157 | } 158 | 159 | private getStatePath(): string { 160 | return path.join(this.basePath, 'homebridge-tion.auth.json'); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/tion/command.ts: -------------------------------------------------------------------------------- 1 | export enum CommandStatus { 2 | Queued = 'queued', 3 | Delivered = 'delivered', 4 | Completed = 'completed', 5 | } 6 | 7 | export interface ICommandResult { 8 | task_id: string; 9 | status: CommandStatus; 10 | type: 'setDeviceMode' | 'setBrightness'; 11 | target_guid: string; 12 | user_guid: string; 13 | email: string; 14 | } 15 | 16 | export enum HeaterMode4S { 17 | On = 'heat', 18 | Off = 'maintenance', 19 | } 20 | 21 | export enum GateState { 22 | Inside3S = 0, 23 | Outside3S = 2, 24 | Inside4S = 1, 25 | Outside4S = 0, 26 | } 27 | 28 | export enum CommandTarget { 29 | Device = 'device', 30 | Zone = 'zone', 31 | } 32 | 33 | export enum CommandType { 34 | Mode = 'mode', 35 | Settings = 'settings', 36 | } 37 | 38 | export interface IBreezerCommand { 39 | is_on: boolean; 40 | speed: number; 41 | speed_min_set: number; 42 | speed_max_set: number; 43 | heater_enabled?: boolean; 44 | heater_mode?: HeaterMode4S; // new in Tion 4S 45 | t_set?: number; 46 | gate?: GateState; 47 | } 48 | 49 | export interface IStationCommand { 50 | backlight: 1 | 0; 51 | } 52 | 53 | export enum ZoneMode { 54 | Auto = 'auto', 55 | Manual = 'manual', 56 | } 57 | 58 | export interface IZoneCommand { 59 | mode: ZoneMode; 60 | co2: number; 61 | } 62 | -------------------------------------------------------------------------------- /src/tion/devices/base.ts: -------------------------------------------------------------------------------- 1 | import {ILog, IHomebridgeAccessory} from 'homebridge/framework'; 2 | import {ITionPlatformConfig} from 'platform_config'; 3 | import {ITionApi} from 'tion/api'; 4 | import {ILocation, IDevice, IZone} from 'tion/state'; 5 | import {CommandType, IBreezerCommand, IStationCommand} from 'tion/command'; 6 | 7 | export abstract class TionDeviceBase { 8 | public readonly zoneId: string; 9 | public readonly id: string; 10 | public readonly name: string; 11 | public readonly modelName: string; 12 | public readonly mac: string; 13 | public readonly firmwareRevision: string; 14 | public readonly hardwareRevision: string; 15 | public isOnline: boolean; 16 | public isCommandRunning: boolean; 17 | 18 | protected readonly log: ILog; 19 | protected readonly config: ITionPlatformConfig; 20 | protected readonly api: ITionApi; 21 | protected readonly serviceRegistry: any; 22 | protected readonly characteristicRegistry: any; 23 | 24 | protected readonly accessories: IHomebridgeAccessory[]; 25 | 26 | constructor( 27 | device: IDevice, 28 | zone: IZone, 29 | log: ILog, 30 | config: ITionPlatformConfig, 31 | api: ITionApi, 32 | serviceRegistry: any, 33 | characteristicRegistry: any 34 | ) { 35 | this.zoneId = zone.guid; 36 | this.id = device.guid; 37 | this.name = device.name; 38 | this.modelName = device.type; 39 | this.mac = device.mac; 40 | this.firmwareRevision = device.firmware; 41 | this.hardwareRevision = device.hardware; 42 | this.isOnline = true; 43 | this.isCommandRunning = false; 44 | 45 | this.log = log; 46 | this.config = config; 47 | this.api = api; 48 | this.serviceRegistry = serviceRegistry; 49 | this.characteristicRegistry = characteristicRegistry; 50 | this.accessories = []; 51 | } 52 | 53 | public abstract addEventHandlers(accessory: IHomebridgeAccessory): void; 54 | public abstract updateState(state: ILocation): void; 55 | 56 | protected abstract parseState(state: ILocation): boolean; 57 | 58 | protected findDeviceInState(state: ILocation): {device: IDevice| null; zone: IZone| null} { 59 | let device: IDevice | null = null; 60 | let zone: IZone | null = null; 61 | 62 | state.zones.forEach(z => { 63 | if (device) { 64 | return; 65 | } 66 | z.devices.forEach(d => { 67 | if (device) { 68 | return; 69 | } 70 | if (d.guid === this.id) { 71 | device = d; 72 | zone = z; 73 | } 74 | }); 75 | }); 76 | if (!device) { 77 | this.log.error(`Device ${this.name} (${this.id}) not found in remote state`); 78 | } 79 | return { 80 | device, 81 | zone, 82 | }; 83 | } 84 | 85 | protected async setState(commandType: CommandType, command: IBreezerCommand | IStationCommand): Promise { 86 | try { 87 | this.isCommandRunning = true; 88 | await this.api.execDeviceCommand(commandType, this.id, command); 89 | } finally { 90 | this.isCommandRunning = false; 91 | } 92 | } 93 | 94 | protected async getState(callback: (err: any, value?: any) => any, getter: () => any): Promise { 95 | try { 96 | const state = await this.api.getSystemState(); 97 | if (this.parseState(state)) { 98 | if (!this.isOnline) { 99 | this.accessories.forEach(a => (a.reachable = false)); 100 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 101 | return callback('Not reachable'); 102 | } 103 | 104 | callback(null, getter()); 105 | } else { 106 | this.log.error(`Device ${this.name} (${this.id}) cannot parse state`); 107 | callback('Cannot parse state'); 108 | } 109 | } catch (err) { 110 | this.log.error('Cannot get system state'); 111 | this.log.error(err); 112 | callback('Cannot get state'); 113 | } 114 | } 115 | 116 | protected rollbackCharacteristic(service: any, characteristic: any, value: string | number | boolean) { 117 | setTimeout(() => { 118 | service.updateCharacteristic(characteristic, value); 119 | }, 100); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/tion/devices/breezer.ts: -------------------------------------------------------------------------------- 1 | import {IHomebridgeAccessory, ILog} from 'homebridge/framework'; 2 | import {TionDeviceBase} from './base'; 3 | import {IDevice, IDeviceData, ILocation, IZone, Mode} from 'tion/state'; 4 | import {CommandType, GateState, HeaterMode4S, IBreezerCommand, ZoneMode} from 'tion/command'; 5 | import {ITionPlatformConfig} from 'platform_config'; 6 | import {ITionApi} from 'tion/api'; 7 | import {SupportedDeviceTypes} from './supported_device_types'; 8 | 9 | export class TionBreezer extends TionDeviceBase { 10 | public isOn: boolean; 11 | public isAuto: boolean; 12 | public currentSpeed: number; 13 | public currentSpeedHomekit: number; 14 | public speedLimit: number; 15 | 16 | public readonly isHeaterInstalled: boolean; 17 | public isHeaterOn: boolean; 18 | public targetTemperature: number; 19 | public currentTemperature: number; 20 | public outsideTemperature: number; 21 | 22 | public filterChangeIndication: boolean; 23 | public filterLifeLevel: number; 24 | 25 | public readonly isAirIntakeInstalled: boolean; 26 | public isAirIntakeOn: boolean; 27 | 28 | private maxSpeed: number; 29 | private maxTargetTemperature: number; 30 | 31 | private speedTick: number; 32 | 33 | private firstParse: boolean; 34 | 35 | constructor( 36 | device: IDevice, 37 | zone: IZone, 38 | log: ILog, 39 | config: ITionPlatformConfig, 40 | api: ITionApi, 41 | serviceRegistry: any, 42 | characteristicRegistry: any 43 | ) { 44 | super(device, zone, log, config, api, serviceRegistry, characteristicRegistry); 45 | 46 | this.isOn = false; 47 | this.currentSpeed = 0; 48 | this.currentSpeedHomekit = 0; 49 | this.speedLimit = device.max_speed || 0; 50 | this.maxSpeed = device.max_speed || 0; 51 | this.maxTargetTemperature = device.t_max || 0; 52 | 53 | this.speedTick = this.maxSpeed ? 1 / this.maxSpeed : 1; 54 | 55 | if (device.data.heater_installed !== undefined) { 56 | // o2 and 3s 57 | this.isHeaterInstalled = Boolean(device.data.heater_installed); 58 | } else if (device.data.heater_type !== undefined) { 59 | // 4s 60 | this.isHeaterInstalled = true; 61 | } else { 62 | log.warn(`Cannot determine heater state ${device.name} for device . Please contact plugin developer.`); 63 | log.warn(`Device debug data: ${JSON.stringify(device)}`); 64 | this.isHeaterInstalled = false; 65 | } 66 | 67 | this.isHeaterOn = false; 68 | this.targetTemperature = 22; 69 | this.currentTemperature = 22; 70 | this.outsideTemperature = 22; 71 | 72 | this.filterChangeIndication = false; 73 | this.filterLifeLevel = 1; 74 | 75 | this.isAirIntakeInstalled = [SupportedDeviceTypes.Breezer3S, SupportedDeviceTypes.Breezer4S].includes( 76 | this.modelName as any 77 | ); 78 | this.isAirIntakeOn = false; 79 | 80 | this.firstParse = true; 81 | } 82 | 83 | public addEventHandlers(accessory: IHomebridgeAccessory): void { 84 | accessory.reachable = true; 85 | 86 | if (this.accessories.find(a => a.UUID === accessory.UUID)) { 87 | return; 88 | } 89 | 90 | this.accessories.push(accessory); 91 | 92 | const airPurifier = accessory.getService(this.serviceRegistry.AirPurifier); 93 | const filter = accessory.getService(this.serviceRegistry.FilterMaintenance); 94 | const heater = accessory.getService(this.serviceRegistry.HeaterCooler); 95 | const outsideTemperature = accessory.getService(this.serviceRegistry.TemperatureSensor); 96 | const airIntakeSwitch = accessory.getService(this.serviceRegistry.Switch); 97 | if (airPurifier) { 98 | airPurifier 99 | .getCharacteristic(this.characteristicRegistry.Active) 100 | .on('get', callback => this.getState(callback, () => (this.isOn ? 1 : 0))) 101 | .on('set', async (value, callback) => { 102 | try { 103 | if (!this.isOnline) { 104 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 105 | return callback('Not reachable'); 106 | } 107 | value = Boolean(value); 108 | if (value !== this.isOn) { 109 | if (this.isAuto) { 110 | await this.setAutoMode(false); 111 | } 112 | await this.setState(CommandType.Mode, this.getCommandData( 113 | value, 114 | this.currentSpeed, 115 | this.isHeaterOn, 116 | this.targetTemperature, 117 | this.isAirIntakeOn 118 | )); 119 | } 120 | this.isOn = value; 121 | airPurifier.setCharacteristic( 122 | this.characteristicRegistry.CurrentAirPurifierState, 123 | value ? 2 : 0 124 | ); 125 | if (heater) { 126 | heater.updateCharacteristic( 127 | this.characteristicRegistry.Active, 128 | value 129 | ? this.isAirIntakeOn 130 | ? 0 131 | : this.isHeaterOn 132 | ? 1 133 | : 0 134 | : 0 135 | ); 136 | } 137 | if (this.config.percentSpeed) { 138 | if (value) { 139 | this.currentSpeedHomekit = this.getHomekitSpeed(this.currentSpeed); 140 | airPurifier.updateCharacteristic( 141 | this.characteristicRegistry.RotationSpeed, 142 | this.currentSpeedHomekit 143 | ); 144 | } 145 | } 146 | if (airIntakeSwitch) { 147 | airIntakeSwitch.updateCharacteristic( 148 | this.characteristicRegistry.On, 149 | value ? this.isAirIntakeOn : false 150 | ); 151 | } 152 | callback(); 153 | } catch (err) { 154 | this.log.error(err.message || err); 155 | callback(err.message || err); 156 | } 157 | }); 158 | 159 | airPurifier 160 | .getCharacteristic(this.characteristicRegistry.CurrentAirPurifierState) 161 | .on('get', callback => this.getState(callback, () => (this.isOn ? 2 : 0))); 162 | 163 | airPurifier 164 | .getCharacteristic(this.characteristicRegistry.TargetAirPurifierState) 165 | .setProps({ 166 | minValue: 0, 167 | maxValue: 1, 168 | validValues: [1], 169 | }) 170 | .on('get', callback => this.getState(callback, () => 1)); 171 | 172 | const rotationSpeedCharacteristic = airPurifier.getCharacteristic( 173 | this.characteristicRegistry.RotationSpeed 174 | ); 175 | if (!this.config.percentSpeed) { 176 | rotationSpeedCharacteristic.setProps({ 177 | minValue: 0, 178 | maxValue: this.maxSpeed, 179 | minStep: 1, 180 | }); 181 | } 182 | 183 | rotationSpeedCharacteristic 184 | .on('get', callback => this.getState(callback, () => this.currentSpeedHomekit)) 185 | .on('set', async (homekitSpeed, callback) => { 186 | try { 187 | if (!this.isOnline) { 188 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 189 | return callback('Not reachable'); 190 | } 191 | const tionSpeed = this.getTionSpeed(homekitSpeed); 192 | if (tionSpeed && tionSpeed !== this.currentSpeed) { 193 | if (this.isAuto) { 194 | await this.setAutoMode(false); 195 | } 196 | await this.setState(CommandType.Mode, this.getCommandData( 197 | this.isOn, 198 | tionSpeed || 1, 199 | this.isHeaterOn, 200 | this.targetTemperature, 201 | this.isAirIntakeOn 202 | )); 203 | } 204 | this.currentSpeed = tionSpeed || 1; 205 | this.currentSpeedHomekit = homekitSpeed; 206 | 207 | callback(); 208 | } catch (err) { 209 | this.log.error(err.message || err); 210 | callback(err.message || err); 211 | } 212 | }); 213 | } 214 | 215 | if (filter) { 216 | filter 217 | .getCharacteristic(this.characteristicRegistry.FilterChangeIndication) 218 | .on('get', callback => this.getState(callback, () => this.filterChangeIndication)); 219 | 220 | filter 221 | .getCharacteristic(this.characteristicRegistry.FilterLifeLevel) 222 | .on('get', callback => this.getState(callback, () => this.filterLifeLevel)); 223 | } 224 | 225 | if (heater) { 226 | heater 227 | .getCharacteristic(this.characteristicRegistry.Active) 228 | .on('get', callback => this.getState(callback, () => (this.isOn && this.isHeaterOn ? 1 : 0))) 229 | .on('set', async (value, callback) => { 230 | try { 231 | if (!this.isOnline) { 232 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 233 | return callback('Not reachable'); 234 | } 235 | if (!this.isOn || this.isAirIntakeOn) { 236 | this.rollbackCharacteristic(heater, this.characteristicRegistry.Active, 0); 237 | return callback(); 238 | } 239 | value = Boolean(value); 240 | if (value !== this.isHeaterOn) { 241 | if (this.isAuto) { 242 | await this.setAutoMode(false); 243 | } 244 | await this.setState(CommandType.Mode, this.getCommandData( 245 | this.isOn, 246 | this.currentSpeed, 247 | value, 248 | this.targetTemperature, 249 | this.isAirIntakeOn 250 | )); 251 | } 252 | this.isHeaterOn = value; 253 | callback(); 254 | } catch (err) { 255 | this.log.error(err.message || err); 256 | callback(err.message || err); 257 | } 258 | }); 259 | 260 | heater 261 | .getCharacteristic(this.characteristicRegistry.CurrentHeaterCoolerState) 262 | .on('get', callback => this.getState(callback, () => (this.isOn && this.isHeaterOn ? 2 : 0))); 263 | 264 | heater 265 | .getCharacteristic(this.characteristicRegistry.TargetHeaterCoolerState) 266 | .setProps({ 267 | maxValue: 2, 268 | minValue: 0, 269 | validValues: [1], 270 | }) 271 | .on('get', callback => this.getState(callback, () => 1)); 272 | 273 | heater 274 | .getCharacteristic(this.characteristicRegistry.CurrentTemperature) 275 | .on('get', callback => this.getState(callback, () => this.currentTemperature)); 276 | 277 | heater 278 | .getCharacteristic(this.characteristicRegistry.HeatingThresholdTemperature) 279 | .setProps({ 280 | minValue: 0, 281 | maxValue: this.maxTargetTemperature, 282 | minStep: 1, 283 | }) 284 | .on('get', callback => this.getState(callback, () => this.targetTemperature)) 285 | .on('set', async (value, callback) => { 286 | try { 287 | if (!this.isOnline) { 288 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 289 | return callback('Not reachable'); 290 | } 291 | if (!this.isOn || this.isAirIntakeOn) { 292 | this.rollbackCharacteristic( 293 | heater, 294 | this.characteristicRegistry.HeatingThresholdTemperature, 295 | this.targetTemperature 296 | ); 297 | return callback(); 298 | } 299 | if (value !== this.targetTemperature) { 300 | if (this.isAuto) { 301 | await this.setAutoMode(false); 302 | } 303 | await this.setState(CommandType.Mode, this.getCommandData( 304 | this.isOn, 305 | this.currentSpeed, 306 | this.isHeaterOn, 307 | value, 308 | this.isAirIntakeOn 309 | )); 310 | } 311 | this.targetTemperature = value; 312 | callback(); 313 | } catch (err) { 314 | this.log.error(err.message || err); 315 | callback(err.message || err); 316 | } 317 | }); 318 | } 319 | 320 | if (outsideTemperature) { 321 | outsideTemperature 322 | .getCharacteristic(this.characteristicRegistry.StatusActive) 323 | .on('get', callback => this.getState(callback, () => (this.isOn ? 1 : 0))); 324 | 325 | outsideTemperature 326 | .getCharacteristic(this.characteristicRegistry.CurrentTemperature) 327 | .setProps({ 328 | minValue: -100, 329 | maxValue: 100, 330 | }) 331 | .on('get', callback => this.getState(callback, () => this.outsideTemperature)); 332 | } 333 | 334 | if (airIntakeSwitch) { 335 | airIntakeSwitch 336 | .getCharacteristic(this.characteristicRegistry.On) 337 | .on('get', callback => this.getState(callback, () => this.isOn && this.isAirIntakeOn)) 338 | .on('set', async (value, callback) => { 339 | try { 340 | if (!this.isOnline) { 341 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 342 | return callback('Not reachable'); 343 | } 344 | if (!this.isOn) { 345 | this.rollbackCharacteristic(airIntakeSwitch, this.characteristicRegistry.On, false); 346 | return callback(); 347 | } 348 | value = Boolean(value); 349 | if (value !== this.isAirIntakeOn) { 350 | if (this.isAuto) { 351 | await this.setAutoMode(false); 352 | } 353 | await this.setState(CommandType.Mode, this.getCommandData( 354 | this.isOn, 355 | this.currentSpeed, 356 | this.isHeaterOn, 357 | this.targetTemperature, 358 | value 359 | )); 360 | } 361 | if (heater) { 362 | heater.updateCharacteristic( 363 | this.characteristicRegistry.Active, 364 | value ? 0 : this.isHeaterOn ? 1 : 0 365 | ); 366 | } 367 | this.isAirIntakeOn = value; 368 | callback(); 369 | } catch (err) { 370 | this.log.error(err.message || err); 371 | callback(err.message || err); 372 | } 373 | }); 374 | } 375 | } 376 | 377 | public updateState(state: ILocation): void { 378 | const action = this.isCommandRunning ? 'updateCharacteristic' : 'setCharacteristic'; 379 | 380 | this.parseState(state); 381 | 382 | this.accessories.forEach(accessory => { 383 | accessory.reachable = this.isOnline; 384 | 385 | const airPurifier = accessory.getService(this.serviceRegistry.AirPurifier); 386 | if (airPurifier) { 387 | airPurifier[action](this.characteristicRegistry.Active, this.isOn ? 1 : 0); 388 | 389 | airPurifier[action](this.characteristicRegistry.CurrentAirPurifierState, this.isOn ? 2 : 0); 390 | 391 | airPurifier[action](this.characteristicRegistry.TargetAirPurifierState, 1); 392 | 393 | airPurifier[action](this.characteristicRegistry.RotationSpeed, this.currentSpeedHomekit); 394 | } 395 | 396 | const filter = accessory.getService(this.serviceRegistry.FilterMaintenance); 397 | if (filter) { 398 | filter[action](this.characteristicRegistry.FilterChangeIndication, this.filterChangeIndication ? 1 : 0); 399 | 400 | filter[action](this.characteristicRegistry.FilterLifeLevel, this.filterLifeLevel); 401 | } 402 | 403 | const heater = accessory.getService(this.serviceRegistry.HeaterCooler); 404 | if (heater) { 405 | heater[action]( 406 | this.characteristicRegistry.Active, 407 | this.isAirIntakeOn 408 | ? 0 409 | : this.isOn && this.isHeaterOn 410 | ? 1 411 | : 0 412 | ); 413 | 414 | heater[action]( 415 | this.characteristicRegistry.CurrentHeaterCoolerState, 416 | this.isAirIntakeOn 417 | ? 0 418 | : this.isOn && this.isHeaterOn 419 | ? 2 420 | : 0 421 | ); 422 | 423 | heater[action](this.characteristicRegistry.TargetHeaterCoolerState, 1); 424 | 425 | heater[action](this.characteristicRegistry.CurrentTemperature, this.currentTemperature); 426 | 427 | heater[action](this.characteristicRegistry.HeatingThresholdTemperature, this.targetTemperature); 428 | } 429 | 430 | const outsideTemperature = accessory.getService(this.serviceRegistry.TemperatureSensor); 431 | if (outsideTemperature) { 432 | outsideTemperature[action](this.characteristicRegistry.StatusActive, this.isOn ? 1 : 0); 433 | outsideTemperature[action](this.characteristicRegistry.CurrentTemperature, this.outsideTemperature); 434 | } 435 | 436 | const airIntakeAccessory = accessory.getService(this.serviceRegistry.Switch); 437 | if (airIntakeAccessory) { 438 | airIntakeAccessory[action]( 439 | this.characteristicRegistry.On, 440 | this.isOn && this.isAirIntakeOn 441 | ); 442 | } 443 | }); 444 | } 445 | 446 | protected parseState(state: ILocation): boolean { 447 | const {device, zone} = this.findDeviceInState(state); 448 | if (!device) { 449 | return false; 450 | } 451 | 452 | this.isOnline = device.is_online; 453 | 454 | this.isOn = device.data.is_on || false; 455 | this.speedLimit = device.data.speed_limit || this.maxSpeed; 456 | this.maxSpeed = device.max_speed || 0; 457 | this.maxTargetTemperature = device.t_max || 0; 458 | this.speedTick = this.maxSpeed ? 1 / this.maxSpeed : 1; 459 | 460 | const newCurrentSpeed = device.data.speed || 1; 461 | if (newCurrentSpeed !== this.currentSpeed) { 462 | this.currentSpeedHomekit = this.getHomekitSpeed(newCurrentSpeed); 463 | } 464 | this.currentSpeed = newCurrentSpeed; 465 | 466 | if (this.isHeaterInstalled) { 467 | this.isHeaterOn = this.getHomekitHeaterIsOn(device.data); 468 | this.targetTemperature = device.data.t_set || 0; 469 | this.currentTemperature = device.data.t_out || 0; 470 | } 471 | 472 | if (this.isOn && !this.firstParse) { 473 | this.outsideTemperature = device.data.t_in || 0; 474 | } 475 | 476 | this.filterChangeIndication = device.data.filter_need_replace || false; 477 | this.filterLifeLevel = device.data.filter_time_seconds 478 | ? device.data.filter_time_seconds / (device.data.filter_time_seconds + (device.data.run_seconds || 0)) 479 | : 0; 480 | 481 | // noinspection SuspiciousTypeOfGuard 482 | if (device.data.gate !== undefined && typeof device.data.gate === 'number') { 483 | this.isAirIntakeOn = this.getHomekitAirIntakeIsOn(device.data.gate); 484 | } 485 | 486 | this.isAuto = zone!.mode?.current === Mode.Auto; 487 | 488 | this.firstParse = false; 489 | 490 | return true; 491 | } 492 | 493 | private getHomekitSpeed(tionSpeed: number): number { 494 | if (this.config.percentSpeed) { 495 | return Math.trunc(this.speedTick * tionSpeed * 100); 496 | } else { 497 | return tionSpeed; 498 | } 499 | } 500 | 501 | private getTionSpeed(homekitSpeed): number { 502 | if (this.config.percentSpeed) { 503 | return Math.ceil(homekitSpeed / 100 / this.speedTick); 504 | } else { 505 | return homekitSpeed; 506 | } 507 | } 508 | 509 | private getTionAirIntakeData(isOn: boolean): any { 510 | switch (this.modelName) { 511 | default: 512 | return {}; 513 | case SupportedDeviceTypes.Breezer3S: 514 | return { 515 | gate: isOn ? GateState.Inside3S : GateState.Outside3S, 516 | }; 517 | case SupportedDeviceTypes.Breezer4S: 518 | return { 519 | gate: isOn ? GateState.Inside4S : GateState.Outside4S, 520 | }; 521 | } 522 | } 523 | 524 | private getHomekitAirIntakeIsOn(gateState: GateState): boolean { 525 | switch (this.modelName) { 526 | default: 527 | return false; 528 | case SupportedDeviceTypes.Breezer3S: 529 | return gateState !== GateState.Outside3S; // has intermediate state, treat it as on 530 | case SupportedDeviceTypes.Breezer4S: 531 | return gateState === GateState.Inside4S; 532 | } 533 | } 534 | 535 | private getTionHeaterData(isHeaterOn: boolean, temperature: number): any { 536 | if (!this.isHeaterInstalled) { 537 | return {}; 538 | } 539 | if (this.modelName === SupportedDeviceTypes.Breezer4S) { 540 | return { 541 | heater_mode: isHeaterOn ? HeaterMode4S.On : HeaterMode4S.Off, 542 | t_set: temperature, 543 | }; 544 | } else { 545 | return { 546 | heater_enabled: isHeaterOn, 547 | t_set: temperature, 548 | }; 549 | } 550 | } 551 | 552 | private getHomekitHeaterIsOn(device: IDeviceData): boolean { 553 | if (!this.isHeaterInstalled) { 554 | return false; 555 | } 556 | switch (this.modelName) { 557 | default: 558 | return !!device.heater_enabled; 559 | case SupportedDeviceTypes.Breezer4S: 560 | return device.heater_mode === HeaterMode4S.On; 561 | } 562 | } 563 | 564 | private getCommandData( 565 | isOn: boolean, 566 | speed: number, 567 | isHeaterOn: boolean, 568 | temperature: number, 569 | isAirIntakeOn: boolean 570 | ): IBreezerCommand { 571 | const heaterData = this.getTionHeaterData(isHeaterOn, temperature); 572 | const airIntakeData = this.getTionAirIntakeData(isAirIntakeOn); 573 | 574 | return { 575 | is_on: isOn, 576 | speed, 577 | speed_min_set: 0, 578 | speed_max_set: Math.min(this.speedLimit, this.maxSpeed), 579 | ...heaterData, 580 | ...airIntakeData, 581 | }; 582 | } 583 | 584 | private async setAutoMode(isAuto: boolean): Promise { 585 | await this.api.execZoneCommand(this.zoneId, { 586 | mode: isAuto ? ZoneMode.Auto : ZoneMode.Manual, 587 | co2: this.config.co2Threshold, 588 | }); 589 | this.isAuto = isAuto; 590 | } 591 | } 592 | -------------------------------------------------------------------------------- /src/tion/devices/co2plus.ts: -------------------------------------------------------------------------------- 1 | import {IHomebridgeAccessory} from 'homebridge/framework'; 2 | import {TionDeviceBase} from './base'; 3 | import {ILocation, IDevice} from 'tion/state'; 4 | 5 | export class TionCO2Plus extends TionDeviceBase { 6 | public co2Level: number = 0; 7 | public temperature: number = 0; 8 | public humidity: number = 0; 9 | 10 | public addEventHandlers(accessory: IHomebridgeAccessory): void { 11 | accessory.reachable = true; 12 | 13 | if (this.accessories.find(a => a.UUID === accessory.UUID)) { 14 | return; 15 | } 16 | 17 | this.accessories.push(accessory); 18 | 19 | const co2Sensor = accessory.getService(this.serviceRegistry.CarbonDioxideSensor); 20 | if (co2Sensor) { 21 | co2Sensor 22 | .getCharacteristic(this.characteristicRegistry.CarbonDioxideDetected) 23 | .on('get', callback => this.getState(callback, () => this.carbonDioxideDetected())); 24 | 25 | co2Sensor 26 | .getCharacteristic(this.characteristicRegistry.CarbonDioxideLevel) 27 | .on('get', callback => this.getState(callback, () => this.co2Level)); 28 | } 29 | 30 | const temperatureSensor = accessory.getService(this.serviceRegistry.TemperatureSensor); 31 | if (temperatureSensor) { 32 | temperatureSensor 33 | .getCharacteristic(this.characteristicRegistry.CurrentTemperature) 34 | .on('get', callback => this.getState(callback, () => this.temperature)); 35 | } 36 | 37 | const humiditySensor = accessory.getService(this.serviceRegistry.HumiditySensor); 38 | if (humiditySensor) { 39 | humiditySensor 40 | .getCharacteristic(this.characteristicRegistry.CurrentRelativeHumidity) 41 | .on('get', callback => this.getState(callback, () => this.humidity)); 42 | } 43 | } 44 | 45 | public updateState(state: ILocation): void { 46 | this.parseState(state); 47 | 48 | this.accessories.forEach(accessory => { 49 | accessory.reachable = this.isOnline; 50 | 51 | const co2Sensor = accessory.getService(this.serviceRegistry.CarbonDioxideSensor); 52 | if (co2Sensor) { 53 | co2Sensor.setCharacteristic( 54 | this.characteristicRegistry.CarbonDioxideDetected, 55 | this.carbonDioxideDetected() 56 | ); 57 | 58 | co2Sensor.setCharacteristic(this.characteristicRegistry.CarbonDioxideLevel, this.co2Level); 59 | } 60 | 61 | const temperatureSensor = accessory.getService(this.serviceRegistry.TemperatureSensor); 62 | if (temperatureSensor) { 63 | temperatureSensor.setCharacteristic(this.characteristicRegistry.CurrentTemperature, this.temperature); 64 | } 65 | 66 | const humiditySensor = accessory.getService(this.serviceRegistry.HumiditySensor); 67 | if (humiditySensor) { 68 | humiditySensor.setCharacteristic(this.characteristicRegistry.CurrentRelativeHumidity, this.humidity); 69 | } 70 | }); 71 | } 72 | 73 | protected parseState(state: ILocation): boolean { 74 | const {device} = this.findDeviceInState(state); 75 | if (!device) { 76 | return false; 77 | } 78 | 79 | this.isOnline = device.is_online; 80 | this.co2Level = device.data?.co2 || 0; 81 | this.temperature = device.data?.temperature || 0; 82 | this.humidity = device.data?.humidity || 0; 83 | 84 | return true; 85 | } 86 | 87 | private carbonDioxideDetected(): 0 | 1 { 88 | return this.co2Level > this.config.co2Threshold ? 1 : 0; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/tion/devices/factory.ts: -------------------------------------------------------------------------------- 1 | import {ILog} from 'homebridge/framework'; 2 | import {ITionPlatformConfig} from 'platform_config'; 3 | 4 | import {ILocation, IDevice, IZone} from 'tion/state'; 5 | import {ITionApi} from 'tion/api'; 6 | import {TionDeviceBase} from './base'; 7 | import {TionMagicAirStation} from './station'; 8 | import {TionBreezer} from './breezer'; 9 | import {TionCO2Plus} from './co2plus'; 10 | import {SupportedDeviceTypes} from './supported_device_types'; 11 | 12 | export interface ITionDevicesFactory { 13 | createDevices(existingDevices: TionDeviceBase[], location: ILocation): TionDeviceBase[]; 14 | } 15 | 16 | export class TionDevicesFactory implements ITionDevicesFactory { 17 | private readonly log: ILog; 18 | private readonly config: ITionPlatformConfig; 19 | private readonly api: ITionApi; 20 | private readonly serviceRegistry: any; 21 | private readonly characteristicRegistry: any; 22 | 23 | constructor( 24 | log: ILog, 25 | config: ITionPlatformConfig, 26 | api: ITionApi, 27 | serviceRegistry: any, 28 | characteristicRegistry: any 29 | ) { 30 | this.log = log; 31 | this.config = config; 32 | this.api = api; 33 | this.serviceRegistry = serviceRegistry; 34 | this.characteristicRegistry = characteristicRegistry; 35 | } 36 | 37 | public createDevices(existingDevices: TionDeviceBase[], location: ILocation): TionDeviceBase[] { 38 | const devices = location.zones.reduce((all: TionDeviceBase[], zone: IZone) => { 39 | const zoneDevices: TionDeviceBase[] = []; 40 | zone.devices.forEach(d => { 41 | if (!existingDevices.find(ex => ex.id === d.guid)) { 42 | const device = this.createDevice(d, zone); 43 | if (device) { 44 | zoneDevices.push(device); 45 | } 46 | } 47 | }); 48 | 49 | return all.concat(zoneDevices); 50 | }, []); 51 | 52 | return existingDevices.concat(devices); 53 | } 54 | 55 | private createDevice(device: IDevice, zone: IZone): TionDeviceBase | null { 56 | switch (device.type) { 57 | default: 58 | this.log.warn( 59 | `Unsupported device type ${device.type}. Please contact plugin developer to add device support.` 60 | ); 61 | return null; 62 | case SupportedDeviceTypes.MagicAirStation: 63 | return new TionMagicAirStation( 64 | device, 65 | zone, 66 | this.log, 67 | this.config, 68 | this.api, 69 | this.serviceRegistry, 70 | this.characteristicRegistry 71 | ); 72 | case SupportedDeviceTypes.Breezer4S: 73 | case SupportedDeviceTypes.Breezer3S: 74 | case SupportedDeviceTypes.BreezerO2: 75 | return new TionBreezer( 76 | device, 77 | zone, 78 | this.log, 79 | this.config, 80 | this.api, 81 | this.serviceRegistry, 82 | this.characteristicRegistry 83 | ); 84 | case SupportedDeviceTypes.CO2Plus: 85 | return new TionCO2Plus( 86 | device, 87 | zone, 88 | this.log, 89 | this.config, 90 | this.api, 91 | this.serviceRegistry, 92 | this.characteristicRegistry 93 | ); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/tion/devices/station.ts: -------------------------------------------------------------------------------- 1 | import {IHomebridgeAccessory} from 'homebridge/framework'; 2 | import {TionDeviceBase} from './base'; 3 | import {ILocation} from 'tion/state'; 4 | import {CommandType} from '../command'; 5 | 6 | export class TionMagicAirStation extends TionDeviceBase { 7 | public co2Level: number = 0; 8 | public temperature: number = 0; 9 | public humidity: number = 0; 10 | public backlight: boolean = false; 11 | 12 | public addEventHandlers(accessory: IHomebridgeAccessory): void { 13 | accessory.reachable = true; 14 | 15 | if (this.accessories.find(a => a.UUID === accessory.UUID)) { 16 | return; 17 | } 18 | 19 | this.accessories.push(accessory); 20 | 21 | const co2Sensor = accessory.getService(this.serviceRegistry.CarbonDioxideSensor); 22 | if (co2Sensor) { 23 | co2Sensor 24 | .getCharacteristic(this.characteristicRegistry.CarbonDioxideDetected) 25 | .on('get', callback => this.getState(callback, () => this.carbonDioxideDetected())); 26 | 27 | co2Sensor 28 | .getCharacteristic(this.characteristicRegistry.CarbonDioxideLevel) 29 | .on('get', callback => this.getState(callback, () => this.co2Level)); 30 | } 31 | 32 | const temperatureSensor = accessory.getService(this.serviceRegistry.TemperatureSensor); 33 | if (temperatureSensor) { 34 | temperatureSensor 35 | .getCharacteristic(this.characteristicRegistry.CurrentTemperature) 36 | .on('get', callback => this.getState(callback, () => this.temperature)); 37 | } 38 | 39 | const humiditySensor = accessory.getService(this.serviceRegistry.HumiditySensor); 40 | if (humiditySensor) { 41 | humiditySensor 42 | .getCharacteristic(this.characteristicRegistry.CurrentRelativeHumidity) 43 | .on('get', callback => this.getState(callback, () => this.humidity)); 44 | } 45 | 46 | const backlightSwitch = accessory.getService(this.serviceRegistry.Switch); 47 | if (backlightSwitch) { 48 | backlightSwitch 49 | .getCharacteristic(this.characteristicRegistry.On) 50 | .on('get', callback => this.getState(callback, () => this.backlight)) 51 | .on('set', async (value, callback) => { 52 | try { 53 | if (!this.isOnline) { 54 | this.log.error(`Device ${this.name} (${this.id}) not reachable`); 55 | return callback('Not reachable'); 56 | } 57 | value = Boolean(value); 58 | if (value !== this.backlight) { 59 | await this.setState(CommandType.Settings, { 60 | backlight: value ? 1 : 0, 61 | }); 62 | } 63 | this.backlight = value; 64 | callback(); 65 | } catch (err) { 66 | this.log.error(err.message || err); 67 | callback(err.message || err); 68 | } 69 | }); 70 | } 71 | 72 | } 73 | 74 | public updateState(state: ILocation): void { 75 | this.parseState(state); 76 | 77 | this.accessories.forEach(accessory => { 78 | accessory.reachable = this.isOnline; 79 | 80 | const co2Sensor = accessory.getService(this.serviceRegistry.CarbonDioxideSensor); 81 | if (co2Sensor) { 82 | co2Sensor.setCharacteristic( 83 | this.characteristicRegistry.CarbonDioxideDetected, 84 | this.carbonDioxideDetected() 85 | ); 86 | 87 | co2Sensor.setCharacteristic(this.characteristicRegistry.CarbonDioxideLevel, this.co2Level); 88 | } 89 | 90 | const temperatureSensor = accessory.getService(this.serviceRegistry.TemperatureSensor); 91 | if (temperatureSensor) { 92 | temperatureSensor.setCharacteristic(this.characteristicRegistry.CurrentTemperature, this.temperature); 93 | } 94 | 95 | const humiditySensor = accessory.getService(this.serviceRegistry.HumiditySensor); 96 | if (humiditySensor) { 97 | humiditySensor.setCharacteristic(this.characteristicRegistry.CurrentRelativeHumidity, this.humidity); 98 | } 99 | 100 | const backlightSwitch = accessory.getService(this.serviceRegistry.Switch); 101 | if (backlightSwitch) { 102 | backlightSwitch.setCharacteristic(this.characteristicRegistry.On, this.backlight); 103 | } 104 | }); 105 | } 106 | 107 | protected parseState(state: ILocation): boolean { 108 | const {device} = this.findDeviceInState(state); 109 | if (!device) { 110 | return false; 111 | } 112 | 113 | this.isOnline = device.is_online; 114 | this.co2Level = device.data?.co2 || 0; 115 | this.temperature = device.data?.temperature || 0; 116 | this.humidity = device.data?.humidity || 0; 117 | this.backlight = Boolean(device.data?.backlight); 118 | 119 | return true; 120 | } 121 | 122 | private carbonDioxideDetected(): 0 | 1 { 123 | return this.co2Level > this.config.co2Threshold ? 1 : 0; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/tion/devices/supported_device_types.ts: -------------------------------------------------------------------------------- 1 | export enum SupportedDeviceTypes { 2 | MagicAirStation = 'co2mb', 3 | Breezer4S = 'breezer4', 4 | Breezer3S = 'breezer3', 5 | BreezerO2 = 'tionO2Rf', 6 | CO2Plus = 'co2Plus', 7 | } 8 | -------------------------------------------------------------------------------- /src/tion/state.ts: -------------------------------------------------------------------------------- 1 | export interface ILocation { 2 | guid: string; 3 | name: string; 4 | comment?: string; 5 | timezone: number; 6 | type: string; 7 | access_level: string; 8 | repository: string; 9 | mac: string; 10 | connection: IConnection; 11 | update: IUpdate; 12 | unique_key: string; 13 | replace_in_progress: boolean; 14 | struct_received: boolean; 15 | order: number; 16 | zones: IZone[]; 17 | creation_time_iso: string; 18 | creation_time: number; 19 | update_time_iso: string; 20 | update_time: number; 21 | } 22 | 23 | export interface IConnection { 24 | state: string; 25 | is_online: boolean; 26 | last_seen_iso: string; 27 | last_seen: number; 28 | last_packet_time_iso: string; 29 | last_packet_time: number; 30 | data_state: string; 31 | last_seen_delta: number; 32 | } 33 | 34 | export interface IUpdate { 35 | state: string; 36 | device_type: string; 37 | mac: number; 38 | mac_human: string; 39 | progress: number; 40 | } 41 | 42 | export interface IZone { 43 | guid: string; 44 | name: string; 45 | type: string; 46 | color: string; 47 | is_virtual: boolean; 48 | mode: IMode; 49 | schedule: ISchedule; 50 | sensors_average: ISensorsAverage[]; 51 | hw_id: number; 52 | devices: IDevice[]; 53 | order: number; 54 | creation_time_iso: string; 55 | creation_time: number; 56 | update_time_iso: string; 57 | update_time: number; 58 | } 59 | 60 | export interface IDevice { 61 | guid: string; 62 | name: string; 63 | type: string; 64 | subtype_d: number; 65 | control_type: string; 66 | mac: string; 67 | mac_long: number; 68 | is_online: boolean; 69 | last_seen_delta: number; 70 | zone_hwid: number; 71 | serial_number: string; 72 | order: number; 73 | data: IDeviceData; 74 | firmware: string; 75 | hardware: string; 76 | creation_time: number; 77 | update_time: number; 78 | temperature_control?: string; 79 | max_speed?: number; 80 | t_max?: number; 81 | t_min?: number; 82 | } 83 | 84 | export interface IDeviceData { 85 | status: string; 86 | 'wi-fi'?: number; 87 | pairing?: IPairing; 88 | co2?: number; 89 | temperature?: number; 90 | humidity?: number; 91 | pm25?: string; 92 | pm10?: string; 93 | signal_level: number; 94 | backlight?: number; 95 | reliability_code?: string; 96 | last_seen_iso?: string; 97 | last_seen?: number; 98 | measurement_time_iso: string; 99 | measurement_time: number; 100 | is_on?: boolean; 101 | data_valid?: boolean; 102 | heater_installed?: boolean; 103 | heater_enabled?: boolean; 104 | heater_type?: string; // new in Tion 4S 105 | heater_mode?: string; // new in Tion 4S 106 | speed?: number; 107 | speed_m3h?: number; 108 | speed_max_set?: number; 109 | speed_min_set?: number; 110 | speed_limit?: number; 111 | t_in?: number; 112 | t_set?: number; 113 | t_out?: number; 114 | gate?: number; 115 | run_seconds?: number; 116 | filter_time_seconds?: number; 117 | rc_controlled?: boolean; 118 | filter_need_replace?: boolean; 119 | errors?: IErrors; 120 | } 121 | 122 | export interface IErrors { 123 | code: string; 124 | list: any[]; 125 | } 126 | 127 | export interface IPairing { 128 | stage: string; 129 | time_left: number; 130 | pairing_result: boolean; 131 | mac: string; 132 | device_type: string; 133 | subtype: string; 134 | subtype_d: number; 135 | } 136 | 137 | export enum Mode { 138 | Auto = 'auto', 139 | Manual = 'manual', 140 | } 141 | 142 | export interface IMode { 143 | current: Mode; 144 | auto_set: IAutoSet; 145 | } 146 | 147 | export interface IAutoSet { 148 | co2: number; 149 | temperature: number; 150 | humidity: number; 151 | noise: number; 152 | pm25: number; 153 | pm10: number; 154 | } 155 | 156 | export interface ISchedule { 157 | is_schedule_sync: boolean; 158 | is_active: boolean; 159 | is_mode_sync: boolean; 160 | current_preset: ICurrentPreset; 161 | next_preset_starts_at: number; 162 | next_starts_iso: string; 163 | } 164 | 165 | export interface ICurrentPreset {} 166 | 167 | export interface ISensorsAverage { 168 | data_type: string; 169 | have_sensors: string[]; 170 | data: ISensorsAverageData; 171 | } 172 | 173 | export interface ISensorsAverageData { 174 | co2: number | string; 175 | temperature: number | string; 176 | humidity: number | string; 177 | pm25: string; 178 | pm10: string; 179 | radon: number; 180 | measurement_time_iso: string; 181 | measurement_time: number; 182 | } 183 | -------------------------------------------------------------------------------- /test/config.test.ts: -------------------------------------------------------------------------------- 1 | import {MockLog, MockPlatformConfig} from './mocks'; 2 | import { validate, sanitize } from '../src/platform_config'; 3 | 4 | describe('Test Platform Config', () => { 5 | 6 | test('Correct config should pass validation', () => { 7 | const config = new MockPlatformConfig(); 8 | 9 | expect(validate(MockLog, config)).toBeTruthy(); 10 | expect(sanitize(MockLog, config)).toEqual({ 11 | name: 'Tion', 12 | homeName: 'Home', 13 | userName: 'test', 14 | password: 'test', 15 | co2Threshold: 799, 16 | apiRequestTimeout: 1001, 17 | percentSpeed: true, 18 | getStateDebounce: 5000 19 | }); 20 | }); 21 | 22 | test('Invalid config should not pass validation', () => { 23 | const config = new MockPlatformConfig(); 24 | // @ts-expect-error The operand of a 'delete' operator must be optional. 25 | delete config.password; 26 | 27 | expect(validate(MockLog, config)).toBeFalsy(); 28 | }); 29 | 30 | test('Invalid config should be sanitized', () => { 31 | const config = new MockPlatformConfig(); 32 | Object.assign(config, { 33 | name: 123, 34 | homeName: 123, 35 | userName: 123, 36 | password: 123, 37 | co2Threshold: -100, 38 | apiRequestTimeout: 0, 39 | percentSpeed: -1 40 | }); 41 | 42 | expect(sanitize(MockLog, config)).toEqual({ 43 | name: 'Tion', 44 | co2Threshold: 800, 45 | apiRequestTimeout: 1500, 46 | percentSpeed: false, 47 | getStateDebounce: 5000 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/data/homebridge-tion.auth.json: -------------------------------------------------------------------------------- 1 | { 2 | "access_token": "71f58fa501d7491fb319bc1594fdbcd2691cd27edca64d218e74ea401a0fc56d", 3 | "expires_in": 1296000, 4 | "token_type": "Bearer", 5 | "refresh_token": "0c9dc4f6440c4c83b87fd5a91445a643dbd2a836858847938d0dfee54d7da87a", 6 | "scope": "ma-account ma-device ma-firmware offline_access", 7 | "username": "admin@google.com", 8 | "user_guid": "37b6c2bc-5b71-4e5e-a44a-1cd890783e77", 9 | "client_id": "a750d720-e146-47b0-b414-35e3b1dd7862", 10 | ".issued": "2020-11-19T14:11:22.1376115Z", 11 | ".expires": "2020-12-04T14:11:22.1376115Z" 12 | } -------------------------------------------------------------------------------- /test/mocks.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'events'; 2 | import {createHash} from 'crypto'; 3 | 4 | import {ITionAuthStorage, ITionAuthData} from '../src/tion/auth'; 5 | import {ITionPlatformConfig} from '../src/platform_config'; 6 | import {IHomebridge, HomebridgePlatform, IHomebridgeApi} from '../src/homebridge/framework'; 7 | 8 | export const MockLog = (...args) => console.log(...args); 9 | MockLog.debug = MockLog; 10 | MockLog.info = MockLog; 11 | MockLog.warn = MockLog; 12 | MockLog.error = MockLog; 13 | MockLog.log = MockLog; 14 | 15 | export class MockTionAuthStorage implements ITionAuthStorage { 16 | private auth: ITionAuthData; 17 | 18 | public save = jest.fn(async (authData: ITionAuthData) => { 19 | this.auth = authData; 20 | }); 21 | 22 | public load = jest.fn(async () => { 23 | return this.auth; 24 | }); 25 | } 26 | 27 | export class MockPlatformConfig implements ITionPlatformConfig { 28 | name: string; 29 | homeName: string; 30 | stationName?: string; 31 | co2Threshold: number; 32 | apiRequestTimeout: number; 33 | userName: string; 34 | password: string; 35 | percentSpeed: boolean; 36 | getStateDebounce: number; 37 | 38 | constructor() { 39 | this.homeName = 'Home'; 40 | this.userName = 'test'; 41 | this.password = 'test'; 42 | this.co2Threshold = 799; 43 | this.apiRequestTimeout = 1001; 44 | this.percentSpeed = true; 45 | this.getStateDebounce = 5000; 46 | } 47 | } 48 | 49 | export class MockPlatformAccessory { 50 | private services: MockServiceBase[]; 51 | private displayName: string; 52 | private UUID: string; 53 | 54 | constructor(displayName: string, uuid: string) { 55 | this.displayName = displayName; 56 | this.UUID = uuid; 57 | this.services = []; 58 | this.services.push(new AccessoryInformation(displayName)); 59 | } 60 | 61 | addService(service: typeof MockServiceBase, name: string): MockServiceBase { 62 | const ret = new service(name); 63 | this.services.push(ret); 64 | return ret; 65 | } 66 | 67 | getService(sClass: typeof MockServiceBase): MockServiceBase | undefined { 68 | const ret = this.services.find(s => s instanceof sClass); 69 | if (!ret) { 70 | const firstService = this.services.find(s => !(s instanceof AccessoryInformation)); 71 | console.log(`Accessory ${this.displayName} with main service ${firstService!.name} is being requested service ${sClass.toString().split(' ')[1]}, but none registered`); 72 | } 73 | return ret; 74 | } 75 | 76 | on = jest.fn((event: string, callback: () => {}) => {}); 77 | } 78 | 79 | class MockServiceBase { 80 | name: string; 81 | characteristics: MockCharacteristicBase[]; 82 | linkedServices: MockServiceBase[]; 83 | 84 | constructor(name: string) { 85 | this.name = name; 86 | this.characteristics = []; 87 | this.linkedServices = []; 88 | } 89 | 90 | addCharacteristic(characteristic: typeof MockCharacteristicBase): MockServiceBase { 91 | this.characteristics.push(new characteristic('')); 92 | return this; 93 | } 94 | 95 | getCharacteristic(characteristic: typeof MockCharacteristicBase): MockCharacteristicBase { 96 | let ret = this.characteristics.find(ch => ch instanceof characteristic); 97 | if (!ret) { 98 | try { 99 | ret = new characteristic(''); 100 | this.characteristics.push(ret); 101 | } catch (err) { 102 | console.log(characteristic); 103 | throw new Error(`No characteristic ${characteristic}`); 104 | } 105 | } 106 | return ret; 107 | } 108 | 109 | setCharacteristic(chClass: typeof MockCharacteristicBase, value: string | number): MockServiceBase { 110 | const ret = this.getCharacteristic(chClass); 111 | ret.value = value; 112 | return this; 113 | } 114 | 115 | updateCharacteristic(chClass: typeof MockCharacteristicBase, value: string | number): MockServiceBase { 116 | const ret = this.getCharacteristic(chClass); 117 | ret.value = value; 118 | return this; 119 | } 120 | 121 | addLinkedService(linkedService: MockServiceBase): void { 122 | this.linkedServices.push(linkedService); 123 | } 124 | } 125 | 126 | class AccessoryInformation extends MockServiceBase {} 127 | 128 | class CarbonDioxideSensor extends MockServiceBase {} 129 | 130 | class TemperatureSensor extends MockServiceBase {} 131 | 132 | class HumiditySensor extends MockServiceBase {} 133 | 134 | class AirPurifier extends MockServiceBase {} 135 | 136 | class FilterMaintenance extends MockServiceBase {} 137 | 138 | class HeaterCooler extends MockServiceBase {} 139 | 140 | class Switch extends MockServiceBase {} 141 | 142 | class MockCharacteristicBase { 143 | value: string | number; 144 | events: {}; 145 | props: {}; 146 | 147 | constructor(value: string | number) { 148 | this.value = value; 149 | this.events = {}; 150 | this.props = {}; 151 | } 152 | 153 | on(direction: 'get' | 'set', fn: any) { 154 | this.events[direction] = fn; 155 | return this; 156 | } 157 | 158 | setValue(value: string | number) { 159 | this.value = value; 160 | } 161 | 162 | setProps(props) { 163 | Object.assign(this.props, props); 164 | return this; 165 | } 166 | } 167 | 168 | class Manufacturer extends MockCharacteristicBase {} 169 | 170 | class Model extends MockCharacteristicBase {} 171 | 172 | class SerialNumber extends MockCharacteristicBase {} 173 | 174 | class FirmwareRevision extends MockCharacteristicBase {} 175 | 176 | class HardwareRevision extends MockCharacteristicBase {} 177 | 178 | class CarbonDioxideLevel extends MockCharacteristicBase {} 179 | 180 | class CarbonDioxideDetected extends MockCharacteristicBase {} 181 | 182 | class CurrentTemperature extends MockCharacteristicBase {} 183 | 184 | class CurrentRelativeHumidity extends MockCharacteristicBase {} 185 | 186 | class Active extends MockCharacteristicBase {} 187 | 188 | class StatusActive extends MockCharacteristicBase {} 189 | 190 | class CurrentAirPurifierState extends MockCharacteristicBase {} 191 | 192 | class TargetAirPurifierState extends MockCharacteristicBase {} 193 | 194 | class RotationSpeed extends MockCharacteristicBase {} 195 | 196 | class FilterChangeIndication extends MockCharacteristicBase {} 197 | 198 | class FilterLifeLevel extends MockCharacteristicBase {} 199 | 200 | class CurrentHeaterCoolerState extends MockCharacteristicBase {} 201 | 202 | class TargetHeaterCoolerState extends MockCharacteristicBase {} 203 | 204 | class HeatingThresholdTemperature extends MockCharacteristicBase {} 205 | 206 | class On extends MockCharacteristicBase {} 207 | 208 | export class MockHomebridge implements IHomebridge { 209 | public hap = { 210 | Service: { 211 | AccessoryInformation, 212 | CarbonDioxideSensor, 213 | TemperatureSensor, 214 | HumiditySensor, 215 | AirPurifier, 216 | FilterMaintenance, 217 | HeaterCooler, 218 | Switch, 219 | }, 220 | Characteristic: { 221 | Manufacturer, 222 | Model, 223 | SerialNumber, 224 | FirmwareRevision, 225 | HardwareRevision, 226 | CarbonDioxideLevel, 227 | CarbonDioxideDetected, 228 | CurrentTemperature, 229 | CurrentRelativeHumidity, 230 | Active, 231 | StatusActive, 232 | CurrentAirPurifierState, 233 | TargetAirPurifierState, 234 | RotationSpeed, 235 | FilterChangeIndication, 236 | FilterLifeLevel, 237 | CurrentHeaterCoolerState, 238 | TargetHeaterCoolerState, 239 | HeatingThresholdTemperature, 240 | On, 241 | }, 242 | uuid: { 243 | generate: (x: string) => 244 | createHash('md5') 245 | .update(x) 246 | .digest('hex'), 247 | }, 248 | }; 249 | public user = {}; 250 | 251 | public platformAccessory = MockPlatformAccessory; 252 | 253 | public registerPlatform = jest.fn( 254 | (identifier: string, name: string, platform: typeof HomebridgePlatform, dynamic: boolean) => {} 255 | ); 256 | } 257 | 258 | export class MockHomebridgeApi implements IHomebridgeApi { 259 | private eventEmitter = new EventEmitter(); 260 | 261 | public registerPlatformAccessories = jest.fn((identifier: string, name: string, accessories: any[]) => {}); 262 | public unregisterPlatformAccessories = jest.fn((identifier: string, name: string, accessories: any[]) => {}); 263 | 264 | public on(eventName: string, callback: () => void) { 265 | this.eventEmitter.on(eventName, callback); 266 | } 267 | 268 | public send(event: 'didFinishLaunching' | 'shutdown') { 269 | this.eventEmitter.emit(event); 270 | } 271 | 272 | public clear() { 273 | this.eventEmitter.removeAllListeners(); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /test/platform.test.ts: -------------------------------------------------------------------------------- 1 | import { TionAuthApi } from '../src/tion/auth'; 2 | import { TionApi } from '../src/tion/api'; 3 | import { TionDevicesFactory } from '../src/tion/devices/factory'; 4 | import { AccessoriesFactory } from '../src/accessories_factory'; 5 | import { TionPlatform } from '../src/platform'; 6 | 7 | import {MockLog, MockTionAuthStorage, MockPlatformConfig, MockHomebridge, MockHomebridgeApi} from './mocks'; 8 | 9 | jest.mock('node-fetch'); 10 | 11 | function setup(): [MockHomebridgeApi, TionPlatform] { 12 | const homebridge = new MockHomebridge(); 13 | const homebridgeApi = new MockHomebridgeApi(); 14 | 15 | const config = new MockPlatformConfig(); 16 | 17 | const authStorage = new MockTionAuthStorage(); 18 | const authApi = new TionAuthApi(MockLog, config, authStorage); 19 | const api = new TionApi(MockLog, config, authApi); 20 | const devicesFactory = new TionDevicesFactory(MockLog, config, api, homebridge.hap.Service, homebridge.hap.Characteristic); 21 | const accessoriesFactory = new AccessoriesFactory(MockLog, homebridge.hap.Service, homebridge.hap.Characteristic, homebridge.platformAccessory, homebridge.hap.uuid.generate); 22 | 23 | const platform = new TionPlatform(MockLog, homebridgeApi, api, devicesFactory, accessoriesFactory); 24 | 25 | return [homebridgeApi, platform]; 26 | } 27 | 28 | describe('Test Tion Platform', () => { 29 | let homebridgeApi: MockHomebridgeApi; 30 | let platform: TionPlatform; 31 | 32 | beforeEach(async () => { 33 | [homebridgeApi, platform] = setup(); 34 | homebridgeApi.send("didFinishLaunching"); 35 | await new Promise((resolve) => setTimeout(resolve, 1000)); 36 | }); 37 | 38 | afterEach(() => { 39 | homebridgeApi.send('shutdown'); 40 | }); 41 | 42 | test('It should register in homebridge', async () => { 43 | expect(platform['tionDevices'].length).toBeGreaterThan(0); 44 | expect(platform['hbAccessories'].length).toBeGreaterThan(0); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/tion_api.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import {TionAuthApi, TionFilesystemAuthStorage} from '../src/tion/auth'; 4 | import {TionApi} from '../src/tion/api'; 5 | 6 | import {MockLog, MockPlatformConfig} from './mocks'; 7 | 8 | jest.mock('node-fetch'); 9 | 10 | describe('Test Tion API', () => { 11 | const config = new MockPlatformConfig(); 12 | 13 | const authStorage = new TionFilesystemAuthStorage(MockLog, path.join(__dirname, 'data')); 14 | const authApi = new TionAuthApi(MockLog, config, authStorage); 15 | const api = new TionApi(MockLog, config, authApi); 16 | 17 | test('It should login and receive state', async () => { 18 | await expect(api.init()).resolves.toBeUndefined(); 19 | const systemState = await api.getSystemState(); 20 | expect(systemState).toBeDefined(); 21 | expect(systemState.name).toEqual('Home'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "strictNullChecks": true, 5 | "outDir": "./dist/", 6 | "sourceMap": true, 7 | "target": "ES6", 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "baseUrl": "./src/", 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "plugins": [ 14 | { "transform": "ts-transformer-imports" } 15 | ] 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "linterOptions": {"exclude": ["test/**"]}, 3 | "defaultSeverity": "error", 4 | "extends": ["tslint:recommended", "tslint-plugin-prettier", "tslint-config-prettier"], 5 | "rules": { 6 | "max-line-length": [true, { "limit": 120, "ignore-pattern": "\".*\"" }], 7 | "no-empty-interface": false, 8 | "quotemark": [true, "single", "avoid-escape", "jsx-double"], 9 | "object-literal-sort-keys": false, 10 | "prefer-const": true, 11 | "ordered-imports": false, 12 | "member-access": true, 13 | "member-ordering": [true, {"order": "statics-first"}], 14 | "semicolon": [true, "always", "ignore-bound-class-methods"], 15 | "arrow-parens": [true, "ban-single-arg-parens"], 16 | "trailing-comma": [ 17 | true, 18 | { 19 | "multiline": { 20 | "objects": "always", 21 | "arrays": "always", 22 | "functions": "never", 23 | "typeLiterals": "ignore" 24 | }, 25 | "esSpecCompliant": true 26 | } 27 | ], 28 | "max-classes-per-file": false 29 | }, 30 | "rulesDirectory": [] 31 | } 32 | --------------------------------------------------------------------------------