├── .eslintrc.js ├── .github └── workflows │ ├── publish-dev.yml │ └── publish.yml ├── .gitignore ├── .prettierrc.js ├── LICENSE ├── README.md ├── build └── nodes │ ├── AccessController.html │ ├── Protect.html │ ├── Request.html │ ├── WebSocket.html │ ├── icons │ ├── protect.png │ └── unifi.png │ └── unifi.html ├── examples ├── Presence_detector.json └── example.json ├── package-lock.json ├── package.json ├── src ├── Endpoints.ts ├── EventModels.ts ├── SharedProtectWebSocket.ts ├── lib │ ├── ProtectApiUpdates.ts │ └── cookieHelper.ts ├── nodes │ ├── AccessController.ts │ ├── Protect.ts │ ├── Request.ts │ ├── WebSocket.ts │ └── unifi.ts ├── test │ └── main.test.ts └── types │ ├── AccessControllerNodeConfigType.ts │ ├── AccessControllerNodeType.ts │ ├── Bootstrap.ts │ ├── ControllerType.ts │ ├── HttpError.ts │ ├── ProtectNodeConfigType.ts │ ├── ProtectNodeType.ts │ ├── RequestNodeConfigType.ts │ ├── RequestNodeInputPayloadType.ts │ ├── RequestNodeType.ts │ ├── UnifiResponse.ts │ ├── WebSocketNodeConfigType.ts │ ├── WebSocketNodeInputPayloadType.ts │ └── WebSocketNodeType.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | env: { 4 | browser: true, 5 | commonjs: true, 6 | es6: true, 7 | node: true, 8 | }, 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | 'prettier', 13 | ], 14 | plugins: ['@typescript-eslint', 'prettier', 'simple-import-sort'], 15 | globals: { 16 | Atomics: 'readonly', 17 | SharedArrayBuffer: 'readonly', 18 | }, 19 | parserOptions: { 20 | ecmaVersion: 2020, 21 | sourceType: 'module', 22 | allowImportExportEverywhere: true, 23 | }, 24 | rules: { 25 | 'prettier/prettier': 'off', 26 | 'linebreak-style': ['error', 'unix'], 27 | quotes: ['error', 'single', { avoidEscape: true }], 28 | semi: ['error', 'never'], 29 | 'no-prototype-builtins': 'off', 30 | '@typescript-eslint/no-this-alias': 'off', 31 | '@typescript-eslint/no-var-requires': 'off', 32 | '@typescript-eslint/no-explicit-any': 'off', 33 | '@typescript-eslint/ban-ts-comment': 'off', 34 | 'simple-import-sort/imports': 'error', 35 | 'simple-import-sort/exports': 'error', 36 | '@typescript-eslint/no-unused-vars': [ 37 | 'error', 38 | { argsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_' }, 39 | ], 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/publish-dev.yml: -------------------------------------------------------------------------------- 1 | name: Publish DEV 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | test: 9 | if: "github.event.release.prerelease" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: 18 16 | - run: | 17 | npm install 18 | npm run build 19 | npm run test 20 | 21 | build-publish: 22 | if: "github.event.release.prerelease" 23 | needs: test 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v2 28 | with: 29 | node-version: 18 30 | registry-url: https://registry.npmjs.org/ 31 | - run: | 32 | npm install 33 | npm run build 34 | npm publish --access public --tag dev 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.NODE_TKN}} 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish RELEASE 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | test: 9 | if: "!github.event.release.prerelease" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: 18 16 | - run: | 17 | npm install 18 | npm run build 19 | npm run test 20 | 21 | build-publish: 22 | if: "!github.event.release.prerelease" 23 | needs: test 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v2 28 | with: 29 | node-version: 18 30 | registry-url: https://registry.npmjs.org/ 31 | - run: | 32 | npm install 33 | npm run build 34 | npm publish --access public 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.NODE_TKN}} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/osx,node,grunt,macos,linux,windows,webstorm,intellij,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=osx,node,grunt,macos,linux,windows,webstorm,intellij,visualstudiocode 3 | 4 | ### grunt ### 5 | # Grunt usually compiles files inside this directory 6 | dist/ 7 | 8 | # Grunt usually preprocesses files such as coffeescript, compass... inside the .tmp directory 9 | .tmp/ 10 | 11 | ### Intellij ### 12 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 13 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 14 | 15 | # User-specific stuff 16 | .idea/**/workspace.xml 17 | .idea/**/tasks.xml 18 | .idea/**/usage.statistics.xml 19 | .idea/**/dictionaries 20 | .idea/**/shelf 21 | 22 | # Generated files 23 | .idea/**/contentModel.xml 24 | 25 | # Sensitive or high-churn files 26 | .idea/**/dataSources/ 27 | .idea/**/dataSources.ids 28 | .idea/**/dataSources.local.xml 29 | .idea/**/sqlDataSources.xml 30 | .idea/**/dynamic.xml 31 | .idea/**/uiDesigner.xml 32 | .idea/**/dbnavigator.xml 33 | 34 | # Gradle 35 | .idea/**/gradle.xml 36 | .idea/**/libraries 37 | 38 | # Gradle and Maven with auto-import 39 | # When using Gradle or Maven with auto-import, you should exclude module files, 40 | # since they will be recreated, and may cause churn. Uncomment if using 41 | # auto-import. 42 | # .idea/modules.xml 43 | # .idea/*.iml 44 | # .idea/modules 45 | 46 | # CMake 47 | cmake-build-*/ 48 | 49 | # Mongo Explorer plugin 50 | .idea/**/mongoSettings.xml 51 | 52 | # File-based project format 53 | *.iws 54 | 55 | # IntelliJ 56 | out/ 57 | 58 | # mpeltonen/sbt-idea plugin 59 | .idea_modules/ 60 | 61 | # JIRA plugin 62 | atlassian-ide-plugin.xml 63 | 64 | # Cursive Clojure plugin 65 | .idea/replstate.xml 66 | 67 | # Crashlytics plugin (for Android Studio and IntelliJ) 68 | com_crashlytics_export_strings.xml 69 | crashlytics.properties 70 | crashlytics-build.properties 71 | fabric.properties 72 | 73 | # Editor-based Rest Client 74 | .idea/httpRequests 75 | 76 | # Android studio 3.1+ serialized cache file 77 | .idea/caches/build_file_checksums.ser 78 | 79 | ### Intellij Patch ### 80 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 81 | 82 | # *.iml 83 | # modules.xml 84 | # .idea/misc.xml 85 | # *.ipr 86 | 87 | # Sonarlint plugin 88 | .idea/sonarlint 89 | 90 | ### Linux ### 91 | *~ 92 | 93 | # temporary files which can be created if a process still has a handle open of a deleted file 94 | .fuse_hidden* 95 | 96 | # KDE directory preferences 97 | .directory 98 | 99 | # Linux trash folder which might appear on any partition or disk 100 | .Trash-* 101 | 102 | # .nfs files are created when an open file is removed but is still being accessed 103 | .nfs* 104 | 105 | ### macOS ### 106 | # General 107 | .DS_Store 108 | .AppleDouble 109 | .LSOverride 110 | 111 | # Icon must end with two \r 112 | Icon 113 | 114 | # Thumbnails 115 | ._* 116 | 117 | # Files that might appear in the root of a volume 118 | .DocumentRevisions-V100 119 | .fseventsd 120 | .Spotlight-V100 121 | .TemporaryItems 122 | .Trashes 123 | .VolumeIcon.icns 124 | .com.apple.timemachine.donotpresent 125 | 126 | # Directories potentially created on remote AFP share 127 | .AppleDB 128 | .AppleDesktop 129 | Network Trash Folder 130 | Temporary Items 131 | .apdisk 132 | 133 | ### Node ### 134 | # Logs 135 | logs 136 | *.log 137 | npm-debug.log* 138 | 139 | # Runtime data 140 | pids 141 | *.pid 142 | *.seed 143 | *.pid.lock 144 | 145 | # Directory for instrumented libs generated by jscoverage/JSCover 146 | lib-cov 147 | 148 | # Coverage directory used by tools like istanbul 149 | coverage 150 | 151 | # nyc test coverage 152 | .nyc_output 153 | 154 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 155 | .grunt 156 | 157 | # Bower dependency directory (https://bower.io/) 158 | bower_components 159 | 160 | # node-waf configuration 161 | .lock-wscript 162 | 163 | # Compiled binary addons (https://nodejs.org/api/addons.html) 164 | build/Release 165 | 166 | # Dependency directories 167 | node_modules/ 168 | jspm_packages/ 169 | 170 | # TypeScript v1 declaration files 171 | typings/ 172 | 173 | # Optional npm cache directory 174 | .npm 175 | 176 | # Optional eslint cache 177 | .eslintcache 178 | 179 | # Optional REPL history 180 | .node_repl_history 181 | 182 | # Output of 'npm pack' 183 | *.tgz 184 | 185 | # dotenv environment variables file 186 | .env 187 | .env.test 188 | 189 | # parcel-bundler cache (https://parceljs.org/) 190 | .cache 191 | 192 | # next.js build output 193 | .next 194 | 195 | # nuxt.js build output 196 | .nuxt 197 | 198 | # vuepress build output 199 | .vuepress/dist 200 | 201 | # Serverless directories 202 | .serverless/ 203 | 204 | # FuseBox cache 205 | .fusebox/ 206 | 207 | # DynamoDB Local files 208 | .dynamodb/ 209 | 210 | ### OSX ### 211 | # General 212 | 213 | # Icon must end with two \r 214 | 215 | # Thumbnails 216 | 217 | # Files that might appear in the root of a volume 218 | 219 | # Directories potentially created on remote AFP share 220 | 221 | ### VisualStudioCode ### 222 | .vscode/* 223 | !.vscode/settings.json 224 | !.vscode/tasks.json 225 | !.vscode/launch.json 226 | !.vscode/extensions.json 227 | 228 | ### VisualStudioCode Patch ### 229 | # Ignore all local history of files 230 | .history 231 | 232 | ### WebStorm ### 233 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 234 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 235 | 236 | # User-specific stuff 237 | 238 | # Generated files 239 | 240 | # Sensitive or high-churn files 241 | 242 | # Gradle 243 | 244 | # Gradle and Maven with auto-import 245 | # When using Gradle or Maven with auto-import, you should exclude module files, 246 | # since they will be recreated, and may cause churn. Uncomment if using 247 | # auto-import. 248 | # .idea/modules.xml 249 | # .idea/*.iml 250 | # .idea/modules 251 | 252 | # CMake 253 | 254 | # Mongo Explorer plugin 255 | 256 | # File-based project format 257 | 258 | # IntelliJ 259 | 260 | # mpeltonen/sbt-idea plugin 261 | 262 | # JIRA plugin 263 | 264 | # Cursive Clojure plugin 265 | 266 | # Crashlytics plugin (for Android Studio and IntelliJ) 267 | 268 | # Editor-based Rest Client 269 | 270 | # Android studio 3.1+ serialized cache file 271 | 272 | ### WebStorm Patch ### 273 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 274 | 275 | # *.iml 276 | # modules.xml 277 | # .idea/misc.xml 278 | # *.ipr 279 | 280 | # Sonarlint plugin 281 | 282 | ### Windows ### 283 | # Windows thumbnail cache files 284 | Thumbs.db 285 | ehthumbs.db 286 | ehthumbs_vista.db 287 | 288 | # Dump file 289 | *.stackdump 290 | 291 | # Folder config file 292 | [Dd]esktop.ini 293 | 294 | # Recycle Bin used on file shares 295 | $RECYCLE.BIN/ 296 | 297 | # Windows Installer files 298 | *.cab 299 | *.msi 300 | *.msix 301 | *.msm 302 | *.msp 303 | 304 | # Windows shortcuts 305 | *.lnk 306 | 307 | # End of https://www.gitignore.io/api/osx,node,grunt,macos,linux,windows,webstorm,intellij,visualstudiocode 308 | 309 | .idea 310 | .vscode 311 | 312 | build/**/*.js 313 | .npm-upgrade.json 314 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 4, 3 | useTabs: false, 4 | semi: false, 5 | singleQuote: true, 6 | trailingComma: 'es5', 7 | endOfLine: 'lf', 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2021] [NRCHKB and it's contributors and 3rd parties who own dependencies] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-red-contrib-unifi-os 2 | 3 | image 4 | 5 | ### * What is this contrib? Why is it different? 6 | This project will give access to all known UniFi http and websocket API endpoints. It is specifically focused on **UniFI OS** consoles, it is not expected to work with older UniFi hardware. The data (tons of it) is passed into Node-RED as JSON data from the HTTP request and WebSocket connection nodes. The internals assist with login cookies and credentials handling. All data from the API is sent on to the user. 7 | 8 | Be warned, it is a lot of data. It will be up to the user to build filters and find the JSON data they are looking for. 9 | 10 | ### * Current Status 11 | Updated July 15, 2021. 12 | 13 | Currently, we have what appears to be a fully functioning setup. We are still looking for errors and bugs from more edge case testing, please report if you find something. 14 | 15 | The HTTP endpoints [listed here](https://ubntwiki.com/products/software/UniFi-controller/api) should all be working properly. GET, POST, and PUT requests should all be functioning and able to pass commands from Node-RED into the UniFi API. 16 | 17 | The WebSocket endpoints are fully functional as well, including push updates from UniFi OS, the Network app, and the Protect app. We have not tested Talk or Access apps - simply because none of us have that hardware, should work fine though. 18 | 19 | ### * Initial Setup 20 | 21 | It is recommended that you create a local admin on your UniFi OS console. This will enable simple login (not 2FA) and allow a local connection between your Node-RED instance and your UniFi console. In order to add a local user, simply go to your UniFi console's user management screen and add a new user, selecting "Local Access" under Account Type. 22 | 23 | As you place your first UniFi node, you will need to create a new config node. This is where you will put your UniFi Console IP address, username, and password. No further work is required to log into your console. 24 | 25 | ### * How to Use HTTP Request Node 26 | 27 | HTTP request nodes can do all the things [listed here](https://ubntwiki.com/products/software/UniFi-controller/api). 28 | 29 | The configuration may be set either by typing into the node's setup fields or by sending payloads including `msg.payload.endpoint`, `msg.payload.method`, and `msg.payload.data`. 30 | 31 | The format of these nodes is a bit different from the list linked above. Here is a very incomplete list of tested endpoints to get started with: 32 | ``` 33 | /proxy/network/api/s/default/stat/health 34 | /proxy/protect/api/bootstrap 35 | /proxy/protect/api/cameras 36 | /proxy/network/api/s/default/stat/sta/ 37 | /proxy/network/api/s/default/cmd/stat 38 | ``` 39 | 40 | Here is an example payload which maybe sent if you would like to send data (POST) to the UniFi Console. This example will reset the DPI counters on your system. **DATA WILL BE REMOVED FROM YOUR UNIFI CONSOLE WHEN SENDING THIS MESSAGE** 41 | ```json 42 | { 43 | "payload": { 44 | "endpoint": "/proxy/network/api/s/default/cmd/stat", 45 | "method": "POST", 46 | "data": {"cmd":"reset-dpi"} 47 | } 48 | } 49 | ``` 50 | 51 | Please use [this excellent list](https://ubntwiki.com/products/software/UniFi-controller/api) to learn all the fun things you might like to send to the HTTP node. 52 | 53 | ### * How to Use WebSocket Node 54 | 55 | The UniFi Consoles are *very talkative*. The websocket nodes are easy to set up, simply put the endpoint into the setup field and deploy. Then watch the data flow. 56 | 57 | Here is a short list of known WebSocket endpoints, please create an issue or share on Discord if you know of more 58 | ``` 59 | /proxy/network/wss/s/default/events 60 | /api/ws/system 61 | /proxy/protect/ws/updates?[QUERY-STRING] 62 | ``` 63 | 64 | That last one is special. It needs a query string from the bootstrap HTTP endpoint. But it's also the most important part of this node. When set up properly it will provide real-time UniFi Protect data into your Node-RED flows. This includes motion detection, doorbell buttons, and smart detections. See the following section for more about this setup. 65 | 66 | ### * Real-Time UniFi Protect API Connection 67 | 68 | This connection is a two-part setup. 69 | 70 | - Step 1: obtain a `bootstrap` payload from the HTTP node. This will come from the endpoint `/proxy/protect/api/bootstrap`. The response from `bootstrap` should have a part called `msg.payload.lastUpdateId` - that is what you will need for the next piece. 71 | - Step 2: connect to a WebSocket endpoint using the `lastUpdateId` obtained in (Step 1) `/proxy/protect/ws/updates?lastUpdateId=${msg.payload.lastUpdateId}`. This websocket will pump out live unifi protect payloads. 72 | 73 | Here is a screenshot of how this looks in practice: 74 | image 75 | 76 | The function node is quite simple, it looks like this inside: 77 | ```js 78 | if ("lastUpdateId" in msg.payload) { 79 | return { 80 | payload: { 81 | endpoint: `/proxy/protect/ws/updates?lastUpdateId=${msg.payload.lastUpdateId}` 82 | } 83 | }; 84 | } 85 | ``` 86 | 87 | Re-authentication *may* be needed after some time. The second output on your WebSocket node will provide any errors due to this connection. Readme will be updated soon (soon after July 15, 2021) with some options for using these errors in re-connect. 88 | 89 | ### Problems, Testing, and Development 90 | 91 | If you have questions, problems, or suggestions please open a topic [here](https://github.com/NRCHKB/node-red-contrib-unifi-os/discussions). Note this is a very new node with limited testing. Please, please open an issue or discussion if you find any problems. 92 | Thanks! 93 | 94 | Additionally, please find us at the `#unifi` channel at our [Discord server](https://discord.gg/RCH3g22YCg) 95 | -------------------------------------------------------------------------------- /build/nodes/AccessController.html: -------------------------------------------------------------------------------- 1 | 46 | 47 | 48 | 96 | 97 | 98 | 104 | -------------------------------------------------------------------------------- /build/nodes/Protect.html: -------------------------------------------------------------------------------- 1 | 135 | 136 | 137 | 175 | 176 | 177 | -------------------------------------------------------------------------------- /build/nodes/Request.html: -------------------------------------------------------------------------------- 1 | 66 | 67 | 114 | 115 | 118 | -------------------------------------------------------------------------------- /build/nodes/WebSocket.html: -------------------------------------------------------------------------------- 1 | 57 | 58 | 78 | 79 | 82 | -------------------------------------------------------------------------------- /build/nodes/icons/protect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCHKB/node-red-contrib-unifi-os/7ccc3d8ddc1eb85db082f7ddfb7558744e19f3fa/build/nodes/icons/protect.png -------------------------------------------------------------------------------- /build/nodes/icons/unifi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NRCHKB/node-red-contrib-unifi-os/7ccc3d8ddc1eb85db082f7ddfb7558744e19f3fa/build/nodes/icons/unifi.png -------------------------------------------------------------------------------- /build/nodes/unifi.html: -------------------------------------------------------------------------------- 1 | 2 | 42 | -------------------------------------------------------------------------------- /examples/Presence_detector.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "8bbbe5fab5a66a42", 4 | "type": "comment", 5 | "z": "a70dcb4b7f48c7cd", 6 | "name": "Unifi presence detector - README", 7 | "info": "The \"Go\" node sends a payload to the Unifi node \nevery 20 seconds.\nIf the device is in idle mode for more than\na certain time (default 5 minutes),\nthe device is considered offline.", 8 | "x": 190, 9 | "y": 250, 10 | "wires": [] 11 | }, 12 | { 13 | "id": "5ca304d82441ea88", 14 | "type": "function", 15 | "z": "a70dcb4b7f48c7cd", 16 | "name": "Massimo's iPhone", 17 | "func": "\n// ### SEARCH TERMS AND TIME ###########################\n// *Both* FRIENDLY NAME and HOSTNAME must be set\nlet sCercaNome = \"iPhone di Massimo\"; // SEARCH FRIENDLY NAME\nlet sCercaHostName = \"iPhonediMassimo\"; // SEARCH HOSTNAME\nlet idleTimeMassimo = 5; // IN MINUTES. AFTER THIS TIME, THE DEVICE IS CONSIDERED NOT CONNECTED ANYMORE\n// #####################################################\n\n// RETURN PAYLOAD: #####################################\n// If the device is connected to the UDM LAN/WIFI, returns TRUE and the found device { payload: true, trovato: sFound }\n// If the device isn't connected anymore,returns { payload: false }\n// #####################################################\n\nif (msg.payload === undefined) return\n\nif (!msg.payload.hasOwnProperty('data') || msg.payload.data === undefined) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"Error: data property not present\" });\n return\n}\nif (msg.payload.data.length === 0) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"Error: data lenght is zero\" });\n return\n}\n\ntry {\n const oElencoUnifi = msg.payload.data;\n let oFoundUnifi = oElencoUnifi.filter(x => x.name === sCercaNome || x.hostname === sCercaHostName);\n if (oFoundUnifi === undefined || oFoundUnifi.length === 0) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"Device not found: \" + sCercaNome });\n oElencoUnifi.length = 0;\n oFoundUnifi.length = 0;\n return { payload: false }\n }\n const sFound = oFoundUnifi[0]\n if (sFound === undefined) return\n if ((sFound.idletime / 60) >= idleTimeMassimo) {\n node.status({ fill: \"red\", shape: \"dot\", text: sFound.name + \" disconnected since: \" + Math.round(sFound.idletime / 60) + \" minutes.\" });\n node.send({ payload: false });\n } else {\n node.status({ fill: \"green\", shape: \"dot\", text: sFound.name + \" connected.\" });\n node.send({ payload: true, trovato: sFound })\n }\n} catch (error) {\n node.status({ fill: \"red\", shape: \"ring\", text:\"Ops.. \" + error.message });\n};\n\n", 18 | "outputs": 1, 19 | "timeout": "", 20 | "noerr": 0, 21 | "initialize": "", 22 | "finalize": "", 23 | "libs": [], 24 | "x": 440, 25 | "y": 300, 26 | "wires": [ 27 | [ 28 | "3674e141ad14b23f" 29 | ] 30 | ] 31 | }, 32 | { 33 | "id": "7a8190bf93c093e3", 34 | "type": "unifi-request", 35 | "z": "a70dcb4b7f48c7cd", 36 | "name": "Read Device list", 37 | "accessControllerNodeId": "", 38 | "endpoint": "/proxy/network/api/s/default/stat/sta/", 39 | "method": "GET", 40 | "data": "", 41 | "dataType": "json", 42 | "responseType": "json", 43 | "x": 245, 44 | "y": 300, 45 | "wires": [ 46 | [ 47 | "5ca304d82441ea88" 48 | ] 49 | ] 50 | }, 51 | { 52 | "id": "1f5244a6053a3796", 53 | "type": "inject", 54 | "z": "a70dcb4b7f48c7cd", 55 | "name": "Go", 56 | "props": [ 57 | { 58 | "p": "payload" 59 | }, 60 | { 61 | "p": "topic", 62 | "vt": "str" 63 | } 64 | ], 65 | "repeat": "10", 66 | "crontab": "", 67 | "once": true, 68 | "onceDelay": "20", 69 | "topic": "", 70 | "payload": "", 71 | "payloadType": "date", 72 | "x": 100, 73 | "y": 300, 74 | "wires": [ 75 | [ 76 | "7a8190bf93c093e3" 77 | ] 78 | ] 79 | }, 80 | { 81 | "id": "3674e141ad14b23f", 82 | "type": "debug", 83 | "z": "a70dcb4b7f48c7cd", 84 | "name": "debug 10", 85 | "active": true, 86 | "tosidebar": true, 87 | "console": false, 88 | "tostatus": false, 89 | "complete": "true", 90 | "targetType": "full", 91 | "statusVal": "", 92 | "statusType": "auto", 93 | "x": 615, 94 | "y": 300, 95 | "wires": [] 96 | } 97 | ] 98 | -------------------------------------------------------------------------------- /examples/example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "d3581e49f2f5c4a2", 4 | "type": "unifi-request", 5 | "z": "677ab7b023898f3c", 6 | "name": "Bootstrap", 7 | "accessControllerNodeId": "c42f07f6222e4d62", 8 | "endpoint": "/proxy/protect/api/bootstrap", 9 | "method": "GET", 10 | "data": "{}", 11 | "dataType": "json", 12 | "responseType": "json", 13 | "x": 440, 14 | "y": 1100, 15 | "wires": [ 16 | [ 17 | "0400011317c5d01d" 18 | ] 19 | ] 20 | }, 21 | { 22 | "id": "de49a794b59ca605", 23 | "type": "inject", 24 | "z": "677ab7b023898f3c", 25 | "name": "", 26 | "props": [], 27 | "repeat": "", 28 | "crontab": "", 29 | "once": false, 30 | "onceDelay": 0.1, 31 | "topic": "", 32 | "x": 310, 33 | "y": 1100, 34 | "wires": [ 35 | [ 36 | "d3581e49f2f5c4a2" 37 | ] 38 | ] 39 | }, 40 | { 41 | "id": "0400011317c5d01d", 42 | "type": "debug", 43 | "z": "677ab7b023898f3c", 44 | "name": "", 45 | "active": true, 46 | "tosidebar": true, 47 | "console": false, 48 | "tostatus": false, 49 | "complete": "true", 50 | "targetType": "full", 51 | "statusVal": "", 52 | "statusType": "auto", 53 | "x": 570, 54 | "y": 1100, 55 | "wires": [] 56 | }, 57 | { 58 | "id": "be25d7c2ba1b7191", 59 | "type": "unifi-web-socket", 60 | "z": "677ab7b023898f3c", 61 | "name": "Events", 62 | "endpoint": "/proxy/network/wss/s/default/events", 63 | "accessControllerNodeId": "c42f07f6222e4d62", 64 | "reconnectTimeout": 30000, 65 | "x": 430, 66 | "y": 1160, 67 | "wires": [ 68 | [ 69 | "545471265873e855" 70 | ], 71 | [] 72 | ] 73 | }, 74 | { 75 | "id": "545471265873e855", 76 | "type": "debug", 77 | "z": "677ab7b023898f3c", 78 | "name": "", 79 | "active": true, 80 | "tosidebar": true, 81 | "console": false, 82 | "tostatus": false, 83 | "complete": "true", 84 | "targetType": "full", 85 | "statusVal": "", 86 | "statusType": "auto", 87 | "x": 570, 88 | "y": 1160, 89 | "wires": [] 90 | }, 91 | { 92 | "id": "c42f07f6222e4d62", 93 | "type": "unifi-access-controller", 94 | "name": "UDM Pro", 95 | "controllerIp": "192.168.1.1", 96 | "controllerType": "UniFiOSConsole" 97 | } 98 | ] 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-red-contrib-unifi-os", 3 | "version": "1.1.0", 4 | "description": "Nodes to access UniFi data using endpoints and websockets", 5 | "main": "build/nodes/unifi.js", 6 | "scripts": { 7 | "build": "npm run clean && tsc", 8 | "clean": "rimraf build/**/*.js", 9 | "test": "mocha -r ts-node/register './src/**/*.test.[tj]s' --exit", 10 | "prettier": "prettier --write \"**/*.{js,ts}\"", 11 | "eslint": "eslint \"src/**/*.ts\"" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/NRCHKB/node-red-contrib-unifi-os" 16 | }, 17 | "keywords": [ 18 | "node-red", 19 | "iot", 20 | "unifi" 21 | ], 22 | "node-red": { 23 | "nodes": { 24 | "unifi": "build/nodes/unifi.js", 25 | "AccessController": "build/nodes/AccessController.js", 26 | "Request": "build/nodes/Request.js", 27 | "WebSocket": "build/nodes/WebSocket.js", 28 | "Protect": "build/nodes/Protect.js" 29 | }, 30 | "version": ">=2.0.0" 31 | }, 32 | "contributors": [ 33 | { 34 | "name": "Garrett Porter", 35 | "email": "hotmail.icloud@yahoo.com", 36 | "url": "https://github.com/crxporter" 37 | }, 38 | { 39 | "name": "Tadeusz Wyrzykowski", 40 | "email": "shaquu@icloud.com", 41 | "url": "https://github.com/Shaquu" 42 | }, 43 | { 44 | "name": "Marcus Davies", 45 | "email": "marcus.davies83@icloud.com", 46 | "url": "https://github.com/marcus-j-davies" 47 | } 48 | ], 49 | "license": "Apache-2.0", 50 | "bugs": { 51 | "url": "https://github.com/NRCHKB/node-red-contrib-unifi-os/issues" 52 | }, 53 | "homepage": "https://github.com/NRCHKB/node-red-contrib-unifi-os#readme", 54 | "dependencies": { 55 | "@nrchkb/logger": "^1.3.3", 56 | "abortcontroller-polyfill": "^1.7.5", 57 | "axios": "^1.3.5", 58 | "cookie": "^0.5.0", 59 | "ws": "8.18.0", 60 | "lodash": "^4.17.21", 61 | "async-mutex": "0.5.0" 62 | }, 63 | "devDependencies": { 64 | "@types/lodash": "^4.14.192", 65 | "@types/mocha": "^10.0.0", 66 | "@types/node": "^10.17.60", 67 | "@types/node-red": "^1.2.1", 68 | "@types/node-red-node-test-helper": "^0.2.2", 69 | "@types/semver": "^7.3.12", 70 | "@types/ws": "^8.5.4", 71 | "@typescript-eslint/eslint-plugin": "^5.40.1", 72 | "@typescript-eslint/parser": "^5.40.1", 73 | "babel-eslint": "^10.1.0", 74 | "eslint": "^8.25.0", 75 | "eslint-config-prettier": "^8.5.0", 76 | "eslint-plugin-prettier": "^4.2.1", 77 | "eslint-plugin-simple-import-sort": "^8.0.0", 78 | "mocha": "^10.1.0", 79 | "nock": "^13.2.9", 80 | "node-red": "^2.2.3", 81 | "node-red-node-test-helper": "^0.3.0", 82 | "prettier": "^2.7.1", 83 | "supports-color": "^8.1.1", 84 | "ts-node": "^10.9.1", 85 | "typescript": "^4.8.4" 86 | }, 87 | "engines": { 88 | "node": ">=18.0.0" 89 | }, 90 | "files": [ 91 | "/build", 92 | "/examples" 93 | ], 94 | "optionalDependencies": { 95 | "bufferutil": "^4.0.7", 96 | "utf-8-validate": "^5.0.10" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Endpoints.ts: -------------------------------------------------------------------------------- 1 | type Controllers = { 2 | login: { 3 | url: string 4 | retry: number 5 | } 6 | logout: { 7 | url: string 8 | } 9 | wsport: number 10 | } 11 | 12 | type Endpoints = { 13 | protocol: { 14 | base: string 15 | webSocket: string 16 | } 17 | UniFiOSConsole: Controllers 18 | UniFiNetworkApplication: Controllers 19 | } 20 | 21 | export const endpoints: Endpoints = { 22 | protocol: { 23 | base: 'https://', 24 | webSocket: 'wss://', 25 | }, 26 | UniFiOSConsole: { 27 | login: { 28 | url: '/api/auth/login', 29 | retry: 5000, 30 | }, 31 | logout: { 32 | url: '/api/auth/logout', 33 | }, 34 | wsport: 443, 35 | }, 36 | UniFiNetworkApplication: { 37 | login: { 38 | url: '/api/login', 39 | retry: 5000, 40 | }, 41 | logout: { 42 | url: '/api/logout', 43 | }, 44 | wsport: 8443, 45 | }, 46 | } 47 | -------------------------------------------------------------------------------- /src/EventModels.ts: -------------------------------------------------------------------------------- 1 | export enum ThumbnailSupport { 2 | START_END = 0, 3 | START_WITH_DELAYED_END = 1, 4 | SINGLE_DELAYED = 2, 5 | SINGLE = 3, 6 | NONE = 4, 7 | } 8 | 9 | export enum CameraIDLocation { 10 | PAYLOAD_CAMERA = 0, 11 | ACTION_ID = 1, 12 | NONE = 2, 13 | ACTION_RECORDID = 3, 14 | } 15 | 16 | export type Metadata = 17 | | { 18 | label: string 19 | id: string 20 | hasMultiple: boolean 21 | valueExpression?: string 22 | thumbnailSupport: ThumbnailSupport 23 | idLocation: CameraIDLocation 24 | sendOnEnd?: boolean 25 | } 26 | | { 27 | label?: string 28 | valueExpression?: string 29 | hasMultiple?: never 30 | id?: never 31 | thumbnailSupport?: never 32 | sendOnEnd?: never 33 | } 34 | 35 | export type UnifiEventModel = { 36 | shapeProfile: Record 37 | startMetadata: Metadata 38 | endMetadata?: Metadata 39 | } 40 | 41 | const EventModels: UnifiEventModel[] = [ 42 | { 43 | shapeProfile: { 44 | action: { 45 | action: 'add', 46 | modelKey: 'event', 47 | }, 48 | payload: { 49 | type: 'smartDetectLine', 50 | }, 51 | }, 52 | startMetadata: { 53 | label: 'Line Crossing Trigger', 54 | hasMultiple: true, 55 | sendOnEnd: true, 56 | id: 'LineCross', 57 | thumbnailSupport: ThumbnailSupport.NONE, 58 | idLocation: CameraIDLocation.ACTION_RECORDID, 59 | }, 60 | endMetadata: { 61 | valueExpression: 62 | '{"detectedTypes":_startData.payload.originalEventData.payload.smartDetectTypes,"linesStatus":payload.metadata.linesStatus,"lineSettings":payload.metadata.linesSettings}', 63 | }, 64 | }, 65 | { 66 | shapeProfile: { 67 | action: { 68 | action: 'add', 69 | modelKey: 'event', 70 | }, 71 | payload: { 72 | type: 'smartAudioDetect', 73 | }, 74 | }, 75 | startMetadata: { 76 | label: 'Audio Detection', 77 | hasMultiple: true, 78 | sendOnEnd: true, 79 | id: 'AudioDetection', 80 | thumbnailSupport: ThumbnailSupport.SINGLE_DELAYED, 81 | idLocation: CameraIDLocation.ACTION_RECORDID, 82 | }, 83 | endMetadata: { 84 | valueExpression: 'payload.smartDetectTypes', 85 | }, 86 | }, 87 | { 88 | shapeProfile: { 89 | action: { 90 | modelKey: 'camera', 91 | }, 92 | payload: { 93 | isMotionDetected: false, 94 | }, 95 | }, 96 | startMetadata: { 97 | label: 'Motion Detection', 98 | hasMultiple: false, 99 | id: 'MotionDetection', 100 | valueExpression: 'payload.isMotionDetected', 101 | thumbnailSupport: ThumbnailSupport.NONE, 102 | idLocation: CameraIDLocation.ACTION_ID, 103 | }, 104 | }, 105 | { 106 | shapeProfile: { 107 | action: { 108 | modelKey: 'camera', 109 | }, 110 | payload: { 111 | isMotionDetected: true, 112 | }, 113 | }, 114 | startMetadata: { 115 | label: 'Motion Detection', 116 | hasMultiple: false, 117 | id: 'MotionDetection', 118 | valueExpression: 'payload.isMotionDetected', 119 | thumbnailSupport: ThumbnailSupport.NONE, 120 | idLocation: CameraIDLocation.ACTION_ID, 121 | }, 122 | }, 123 | { 124 | shapeProfile: { 125 | action: { 126 | action: 'add', 127 | }, 128 | payload: { 129 | type: 'motion', 130 | }, 131 | }, 132 | startMetadata: { 133 | label: 'Motion Event', 134 | hasMultiple: true, 135 | id: 'MotionEvent', 136 | thumbnailSupport: ThumbnailSupport.START_WITH_DELAYED_END, 137 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 138 | }, 139 | }, 140 | { 141 | shapeProfile: { 142 | action: { 143 | action: 'add', 144 | }, 145 | payload: { 146 | type: 'ring', 147 | }, 148 | }, 149 | startMetadata: { 150 | label: 'Door Bell Ring', 151 | hasMultiple: false, 152 | id: 'DoorBell', 153 | thumbnailSupport: ThumbnailSupport.SINGLE_DELAYED, 154 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 155 | }, 156 | }, 157 | { 158 | shapeProfile: { 159 | action: { 160 | action: 'add', 161 | }, 162 | payload: { 163 | type: 'smartDetectZone', 164 | smartDetectTypes: ['package'], 165 | }, 166 | }, 167 | startMetadata: { 168 | label: 'Package Detected', 169 | hasMultiple: false, 170 | id: 'Package', 171 | thumbnailSupport: ThumbnailSupport.SINGLE_DELAYED, 172 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 173 | }, 174 | }, 175 | { 176 | shapeProfile: { 177 | action: { 178 | action: 'add', 179 | }, 180 | payload: { 181 | type: 'smartDetectZone', 182 | smartDetectTypes: ['vehicle'], 183 | }, 184 | }, 185 | startMetadata: { 186 | label: 'Vehicle Detected', 187 | hasMultiple: true, 188 | id: 'Vehicle', 189 | thumbnailSupport: ThumbnailSupport.START_WITH_DELAYED_END, 190 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 191 | }, 192 | }, 193 | { 194 | shapeProfile: { 195 | action: { 196 | action: 'add', 197 | }, 198 | payload: { 199 | type: 'smartDetectZone', 200 | smartDetectTypes: ['person'], 201 | }, 202 | }, 203 | startMetadata: { 204 | label: 'Person Detected', 205 | hasMultiple: true, 206 | id: 'Person', 207 | thumbnailSupport: ThumbnailSupport.START_WITH_DELAYED_END, 208 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 209 | }, 210 | }, 211 | { 212 | shapeProfile: { 213 | action: { 214 | action: 'add', 215 | }, 216 | payload: { 217 | type: 'smartDetectZone', 218 | smartDetectTypes: ['animal'], 219 | }, 220 | }, 221 | startMetadata: { 222 | label: 'Animal Detected', 223 | hasMultiple: true, 224 | id: 'Animal', 225 | thumbnailSupport: ThumbnailSupport.START_WITH_DELAYED_END, 226 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 227 | }, 228 | }, 229 | { 230 | shapeProfile: { 231 | action: { 232 | action: 'add', 233 | }, 234 | payload: { 235 | type: 'smartDetectZone', 236 | smartDetectTypes: ['licensePlate'], 237 | }, 238 | }, 239 | startMetadata: { 240 | label: 'License Plate Scan', 241 | hasMultiple: true, 242 | id: 'LicensePlate', 243 | thumbnailSupport: ThumbnailSupport.START_WITH_DELAYED_END, 244 | idLocation: CameraIDLocation.PAYLOAD_CAMERA, 245 | }, 246 | }, 247 | ] 248 | 249 | export default EventModels 250 | -------------------------------------------------------------------------------- /src/SharedProtectWebSocket.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@nrchkb/logger' 2 | import { Loggers } from '@nrchkb/logger/src/types' 3 | import { Mutex } from 'async-mutex' 4 | import WebSocket, { OPEN, RawData } from 'ws' 5 | 6 | import { endpoints } from './Endpoints' 7 | import { ProtectApiUpdates } from './lib/ProtectApiUpdates' 8 | import AccessControllerNodeConfigType from './types/AccessControllerNodeConfigType' 9 | import AccessControllerNodeType from './types/AccessControllerNodeType' 10 | import { Bootstrap } from './types/Bootstrap' 11 | 12 | export enum SocketStatus { 13 | UNKNOWN = 0, 14 | CONNECTING = 1, 15 | CONNECTED = 2, 16 | RECOVERING_CONNECTION = 3, 17 | CONNECTION_ERROR = 4, 18 | HEARTBEAT = 5, 19 | } 20 | 21 | export type WSDataCallback = (data: any) => void 22 | export type WSStatusCallback = (status: SocketStatus) => void 23 | 24 | export interface Interest { 25 | dataCallback: WSDataCallback 26 | statusCallback: WSStatusCallback 27 | } 28 | 29 | export class SharedProtectWebSocket { 30 | private bootstrap: Bootstrap 31 | private callbacks: { [nodeId: string]: Interest } 32 | private ws?: WebSocket 33 | private accessControllerConfig: AccessControllerNodeConfigType 34 | private accessController: AccessControllerNodeType 35 | private wsLogger: Loggers 36 | private RECONNECT_TIMEOUT = 15000 37 | private HEARTBEAT_INTERVAL = 30000 38 | private INITIAL_CONNECT_ERROR_THRESHOLD = 1000 39 | private reconnectAttempts = 0 40 | private currentStatus: SocketStatus = SocketStatus.UNKNOWN 41 | 42 | constructor( 43 | AccessController: AccessControllerNodeType, 44 | config: AccessControllerNodeConfigType, 45 | initialBootstrap: Bootstrap 46 | ) { 47 | this.bootstrap = initialBootstrap 48 | this.callbacks = {} 49 | this.accessControllerConfig = config 50 | this.accessController = AccessController 51 | 52 | if (this.accessControllerConfig.protectSocketHeartbeatInterval) { 53 | this.HEARTBEAT_INTERVAL = parseInt( 54 | this.accessControllerConfig.protectSocketHeartbeatInterval 55 | ) 56 | } 57 | 58 | if (this.accessControllerConfig.protectSocketReconnectTimeout) { 59 | this.RECONNECT_TIMEOUT = parseInt( 60 | this.accessControllerConfig.protectSocketReconnectTimeout 61 | ) 62 | } 63 | 64 | this.wsLogger = logger('UniFi', 'SharedProtectWebSocket') 65 | 66 | this.connect() 67 | } 68 | 69 | shutdown(): void { 70 | this.wsLogger?.debug( 71 | 'shutdown()' 72 | ) 73 | this.disconnect() 74 | this.callbacks = {} 75 | } 76 | 77 | private async disconnect(): Promise { 78 | 79 | this.wsLogger?.debug( 80 | 'Disconnecting websocket' 81 | ) 82 | if (this.reconnectTimer) { 83 | clearTimeout(this.reconnectTimer) 84 | this.reconnectTimer = undefined 85 | } 86 | 87 | try { 88 | this.ws?.removeAllListeners() 89 | if (this.ws?.readyState === OPEN) { 90 | //this.ws?.close() 91 | //this.ws?.terminate() 92 | } 93 | this.ws?.terminate() // Terminate anyway 94 | this.ws = undefined 95 | } catch (error) { 96 | this.wsLogger?.debug( 97 | 'Disconnecting websocket error '+ (error as Error).stack 98 | ) 99 | } 100 | 101 | 102 | } 103 | 104 | private updateStatusForNodes = (Status: SocketStatus): Promise => { 105 | this.currentStatus = Status 106 | return new Promise((resolve) => { 107 | Object.keys(this.callbacks).forEach((ID) => { 108 | this.callbacks[ID].statusCallback(Status) 109 | }) 110 | 111 | resolve() 112 | }) 113 | } 114 | 115 | private reconnectTimer: NodeJS.Timeout | undefined 116 | private heartBeatTimer: NodeJS.Timeout | undefined 117 | private mutex = new Mutex() 118 | private async reset(): Promise { 119 | this.wsLogger?.debug( 120 | 'PONG received' 121 | ) 122 | await this.mutex.runExclusive(async () => { 123 | if (this.reconnectTimer) { 124 | clearTimeout(this.reconnectTimer) 125 | this.reconnectTimer = undefined 126 | await this.updateStatusForNodes(SocketStatus.CONNECTED) 127 | try { 128 | this.watchDog() 129 | } catch (error) { 130 | this.wsLogger?.error( 131 | 'reset watchdog error: ' + (error as Error).stack 132 | ) 133 | } 134 | } 135 | }) 136 | } 137 | 138 | private async watchDog(): Promise { 139 | 140 | if (this.heartBeatTimer!==undefined) clearTimeout(this.heartBeatTimer) 141 | this.heartBeatTimer = setTimeout(async () => { 142 | this.wsLogger?.debug( 143 | 'heartBeatTimer kicked in' 144 | ) 145 | await this.updateStatusForNodes(SocketStatus.HEARTBEAT) 146 | if (!this.ws || this.ws?.readyState !== WebSocket.OPEN) { 147 | return 148 | } 149 | try { 150 | this.wsLogger?.debug( 151 | 'gonna PING the server...' 152 | ) 153 | this.ws?.ping() 154 | } catch (error) { 155 | this.wsLogger?.error( 156 | 'PING error: ' + (error as Error).stack 157 | ) 158 | } 159 | 160 | if (this.reconnectTimer!==undefined) clearTimeout(this.reconnectTimer) 161 | this.reconnectTimer = setTimeout(async () => { 162 | this.wsLogger?.debug( 163 | 'reconnectTimer kicked in' 164 | ) 165 | await this.mutex.runExclusive(async () => { 166 | await this.disconnect() 167 | await this.updateStatusForNodes( 168 | SocketStatus.RECOVERING_CONNECTION 169 | ) 170 | try { 171 | await this.connect() 172 | } catch (error) { 173 | this.wsLogger?.error( 174 | 'connect into reconnectTimer error: ' + (error as Error).stack 175 | ) 176 | } 177 | 178 | }) 179 | }, this.RECONNECT_TIMEOUT) 180 | 181 | }, this.HEARTBEAT_INTERVAL) 182 | } 183 | 184 | private processData(Data: RawData): void { 185 | let objectToSend: any 186 | 187 | try { 188 | objectToSend = JSON.parse(Data.toString()) 189 | } catch (_) { 190 | objectToSend = ProtectApiUpdates.decodeUpdatePacket( 191 | this.wsLogger, 192 | Data as Buffer 193 | ) 194 | } 195 | 196 | Object.keys(this.callbacks).forEach((Node) => { 197 | const Interest = this.callbacks[Node] 198 | Interest.dataCallback(objectToSend) 199 | }) 200 | } 201 | 202 | 203 | 204 | private connectCheckInterval: NodeJS.Timeout | undefined 205 | private connectMutex = new Mutex() 206 | 207 | private async connect(): Promise { 208 | 209 | await this.mutex.runExclusive(async () => { 210 | if (this.currentStatus !== SocketStatus.RECOVERING_CONNECTION) { 211 | await this.updateStatusForNodes(SocketStatus.CONNECTING) 212 | } 213 | 214 | const wsPort = 215 | this.accessControllerConfig.wsPort || 216 | endpoints[this.accessController.controllerType].wsport 217 | const url = `${endpoints.protocol.webSocket}${this.accessControllerConfig.controllerIp}:${wsPort}/proxy/protect/ws/updates?lastUpdateId=${this.bootstrap.lastUpdateId}` 218 | 219 | this.disconnect() 220 | 221 | try { 222 | this.ws = new WebSocket(url, { 223 | rejectUnauthorized: false, 224 | headers: { 225 | Cookie: await this.accessController.getAuthCookie(), 226 | }, 227 | }) 228 | this.ws.on('error', (error) => { 229 | this.wsLogger?.error( 230 | 'connect(): this.ws.on(error: ' + (error as Error).stack 231 | ) 232 | }) 233 | this.ws.on('pong', this.reset.bind(this)) 234 | this.ws.on('message', this.processData.bind(this)) 235 | } catch (error) { 236 | this.wsLogger.error( 237 | 'Error instantiating websocket ' + (error as Error).stack 238 | ) 239 | clearInterval(this.connectCheckInterval!) 240 | this.connectCheckInterval = undefined 241 | this.reconnectAttempts = 0 242 | this.watchDog() 243 | } 244 | 245 | 246 | this.connectCheckInterval = setInterval(async () => { 247 | await this.connectMutex.runExclusive(async () => { 248 | switch (this.ws?.readyState) { 249 | case WebSocket.OPEN: 250 | clearInterval(this.connectCheckInterval!) 251 | this.connectCheckInterval = undefined 252 | await this.updateStatusForNodes( 253 | SocketStatus.CONNECTED 254 | ) 255 | this.reconnectAttempts = 0 256 | this.watchDog() 257 | break 258 | 259 | case WebSocket.CONNECTING: 260 | // Do nothing, just keep waiting. 261 | break 262 | 263 | case WebSocket.CLOSED: 264 | case WebSocket.CLOSING: 265 | if ( 266 | this.reconnectAttempts > 267 | this.INITIAL_CONNECT_ERROR_THRESHOLD 268 | ) { 269 | clearInterval(this.connectCheckInterval!) 270 | this.connectCheckInterval = undefined 271 | await this.updateStatusForNodes( 272 | SocketStatus.CONNECTION_ERROR 273 | ) 274 | } else { 275 | clearInterval(this.connectCheckInterval!) 276 | this.connectCheckInterval = undefined 277 | this.reconnectAttempts++ 278 | setTimeout(async () => { 279 | try { 280 | await this.disconnect() 281 | await this.connect() 282 | } catch (error) { 283 | this.wsLogger?.error( 284 | 'Websocket disconnecting error ' + (error as Error).stack 285 | ) 286 | } 287 | 288 | }, this.RECONNECT_TIMEOUT) 289 | } 290 | break 291 | } 292 | }) 293 | }, 5000) 294 | }) 295 | } 296 | 297 | deregisterInterest(nodeId: string): void { 298 | delete this.callbacks[nodeId] 299 | } 300 | 301 | registerInterest(nodeId: string, interest: Interest): SocketStatus { 302 | this.callbacks[nodeId] = interest 303 | return this.currentStatus 304 | } 305 | 306 | updateLastUpdateId(newBootstrap: Bootstrap): void { 307 | if (newBootstrap.lastUpdateId !== this.bootstrap.lastUpdateId) { 308 | this.disconnect() 309 | this.bootstrap = newBootstrap 310 | this.connect() 311 | } else { 312 | this.bootstrap = newBootstrap 313 | } 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/lib/ProtectApiUpdates.ts: -------------------------------------------------------------------------------- 1 | /* Copyright(C) 2019-2022, HJD (https://github.com/hjdhjd). All rights reserved. 2 | * 3 | * protect-api-updates.ts: Our UniFi Protect realtime updates event API implementation. 4 | */ 5 | import { Loggers } from '@nrchkb/logger/src/types' 6 | import zlib from 'zlib' 7 | 8 | // This type declaration make all properties optional recursively including nested objects. This should 9 | // only be used on JSON objects only. Otherwise...you're going to end up with class methods marked as 10 | // optional as well. Credit for this belongs to: https://github.com/joonhocho/tsdef. #Grateful 11 | type DeepPartial = { 12 | [P in keyof T]?: T[P] extends Array 13 | ? Array> 14 | : DeepPartial 15 | } 16 | 17 | interface ProtectCameraLcdMessageConfigInterface { 18 | duration: number 19 | resetAt: number | null 20 | text: string 21 | type: string 22 | } 23 | 24 | type ProtectCameraLcdMessagePayload = 25 | DeepPartial 26 | 27 | /* 28 | * The UniFi Protect realtime updates API is largely undocumented and has been reverse engineered mostly through 29 | * trial and error, as well as observing the Protect controller in action. 30 | * 31 | * Here's how to get started with the UniFi Protect Updates API: 32 | * 33 | * 1. Login to the UniFi Protect controller, obtain the bootstrap JSON. 34 | * 2. Open the websocket to the updates URL (see protect-api.ts). 35 | * 36 | * Then you're ready to listen to messages. You can see an example of this in protect-nvr.ts. 37 | * 38 | * Those are the basics and gets us up and running. Now, to explain how the updates API works... 39 | * 40 | * UniFi OS update data packets are used to provide a realtime stream of updates to Protect. It differs from 41 | * the system events API in that the system events API appears to be shared across other applications (Network, Access, etc.) 42 | * while the updates events API appears to only be utilized by Protect and not shared by other applications, although the protocol 43 | * is shared. 44 | * 45 | * So how does it all work? Cameras continuously stream updates to the UniFi Protect controller containing things like camera 46 | * health, statistics, and - crucially for us - events such as motion and doorbell ring. A complete update packet is composed of four 47 | * frames: 48 | * 49 | * Header Frame (8 bytes) 50 | * Action Frame 51 | * Header Frame (8 bytes) 52 | * Data Frame 53 | * 54 | * The header frame is required overhead since websockets provide only a transport medium. It's purpose is to tell us what's 55 | * coming in the frame that follows. 56 | * 57 | * The action frame identifies what the action and category that the update contains: 58 | * 59 | * Property Description 60 | * -------- ----------- 61 | * action What action is being taken. Known actions are "add" and "update". 62 | * id The identifier for the device we're updating. 63 | * modelKey The device model category that we're updating. 64 | * newUpdateId A new UUID generated on a per-update basis. This can be safely ignored it seems. 65 | * 66 | * The final part of the update packet is the data frame. The data frame can be three different types of data - although in 67 | * practice, I've only seen JSONs come across. Those types are: 68 | * 69 | * Payload Type Description 70 | * 1 JSON. For update actions that are not events, this is always a subset of the configuration bootstrap JSON. 71 | * 2 A UTF8-encoded string 72 | * 3 Node Buffer 73 | * 74 | * Some tips: 75 | * 76 | * - "update" actions are always tied to the following modelKeys: camera, event, nvr, and user. 77 | * 78 | * - "add" actions are always tied to the "event" modelKey and indicate the beginning of an event item in the Protect events list. 79 | * A subsequent "update" action is sent signaling the end of the event capture, and it's confidence score for motion detection. 80 | * 81 | * - The above is NOT the same thing as motion detection. If you want to detect motion, you should watch the "update" action for "camera" 82 | * modelKeys, and look for a JSON that updates lastMotion. For doorbell rings, lastRing. The Protect events list is useful for the 83 | * Protect app, but it's of limited utility to HomeKit, and it's slow - relative to looking for lastMotion that is. If you want true 84 | * realtime updates, you want to look at the "update" action. 85 | * 86 | * - JSONs are only payload type that seems to be sent, although the protocol is designed to accept all three. 87 | * 88 | * - With the exception of update actions with a modelKey of event, JSONs are always a subset of the bootstrap JSON, indexed off 89 | * of modelKey. So for a modelKey of camera, the data payload is always a subset of ProtectCameraConfigInterface (see protect-types.ts). 90 | */ 91 | 92 | // Update realtime API packet header size, in bytes. 93 | const UPDATE_PACKET_HEADER_SIZE = 8 94 | 95 | // Update realtime API packet types. 96 | enum UpdatePacketType { 97 | ACTION = 1, 98 | PAYLOAD = 2, 99 | } 100 | 101 | // Update realtime API payload types. 102 | enum UpdatePayloadType { 103 | JSON = 1, 104 | STRING = 2, 105 | BUFFER = 3, 106 | } 107 | 108 | /* A packet header is composed of 8 bytes in this order: 109 | * 110 | * Byte Offset Description Bits Values 111 | * 0 Packet Type 8 1 - action frame, 2 - payload frame. 112 | * 1 Payload Format 8 1 - JSON object, 2 - UTF8-encoded string, 3 - Node Buffer. 113 | * 2 Deflated 8 0 - uncompressed, 1 - compressed / deflated (zlib-based compression). 114 | * 3 Unknown 8 Always 0. Possibly reserved for future use by Ubiquiti? 115 | * 4-7 Payload Size: 32 Size of payload in network-byte order (big endian). 116 | */ 117 | enum UpdatePacketHeader { 118 | TYPE = 0, 119 | PAYLOAD_FORMAT = 1, 120 | DEFLATED = 2, 121 | UNKNOWN = 3, 122 | PAYLOAD_SIZE = 4, 123 | } 124 | 125 | // A complete description of the UniFi Protect realtime update events API packet format. 126 | type ProtectNvrUpdatePacket = { 127 | action: ProtectNvrUpdateEventAction 128 | payload: Record | string | Buffer 129 | } 130 | 131 | // A complete description of the UniFi Protect realtime update events API action packet JSON. 132 | type ProtectNvrUpdateEventAction = { 133 | action: string 134 | id: string 135 | modelKey: string 136 | newUpdateId: string 137 | } 138 | 139 | // A complete description of the UniFi Protect realtime update events API payload packet JSONs. 140 | // Payload JSON for modelKey: event action: add 141 | export type ProtectNvrUpdatePayloadEventAdd = { 142 | camera: string 143 | id: string 144 | modelKey: string 145 | partition: null 146 | score: number 147 | smartDetectEvents: string[] 148 | smartDetectTypes: string[] 149 | start: number 150 | type: string 151 | } 152 | 153 | // Payload JSON for modelKey: camera action: update 154 | export type ProtectNvrUpdatePayloadCameraUpdate = { 155 | isMotionDetected: boolean 156 | lastMotion: number 157 | lastRing: number 158 | lcdMessage: ProtectCameraLcdMessagePayload 159 | } 160 | 161 | export class ProtectApiUpdates { 162 | // Process an update data packet and return the action and payload. 163 | public static decodeUpdatePacket( 164 | log: Loggers, 165 | packet: Buffer 166 | ): ProtectNvrUpdatePacket | null { 167 | // What we need to do here is to split this packet into the header and payload, and decode them. 168 | 169 | let dataOffset 170 | 171 | try { 172 | // The fourth byte holds our payload size. When you add the payload size to our header frame size, you get the location of the 173 | // data header frame. 174 | dataOffset = 175 | packet.readUInt32BE(UpdatePacketHeader.PAYLOAD_SIZE) + 176 | UPDATE_PACKET_HEADER_SIZE 177 | 178 | // Validate our packet size, just in case we have more or less data than we expect. If we do, we're done for now. 179 | if ( 180 | packet.length !== 181 | dataOffset + 182 | UPDATE_PACKET_HEADER_SIZE + 183 | packet.readUInt32BE( 184 | dataOffset + UpdatePacketHeader.PAYLOAD_SIZE 185 | ) 186 | ) { 187 | // noinspection ExceptionCaughtLocallyJS 188 | throw new Error( 189 | "Packet length doesn't match header information." 190 | ) 191 | } 192 | } catch (error: any) { 193 | log.error( 194 | 'Realtime update API: error decoding update packet: %s.', 195 | error 196 | ) 197 | return null 198 | } 199 | 200 | // Decode the action and payload frames now that we know where everything is. 201 | const actionFrame = this.decodeUpdateFrame( 202 | log, 203 | packet.slice(0, dataOffset), 204 | UpdatePacketType.ACTION 205 | ) as ProtectNvrUpdateEventAction 206 | const payloadFrame = this.decodeUpdateFrame( 207 | log, 208 | packet.slice(dataOffset), 209 | UpdatePacketType.PAYLOAD 210 | ) 211 | 212 | if (!actionFrame || !payloadFrame) { 213 | return null 214 | } 215 | 216 | return { action: actionFrame, payload: payloadFrame } 217 | } 218 | 219 | // Decode a frame, composed of a header and payload, received through the update events API. 220 | private static decodeUpdateFrame( 221 | log: Loggers, 222 | packet: Buffer, 223 | packetType: number 224 | ): 225 | | ProtectNvrUpdateEventAction 226 | | Record 227 | | string 228 | | Buffer 229 | | null { 230 | // Read the packet frame type. 231 | const frameType = packet.readUInt8(UpdatePacketHeader.TYPE) 232 | 233 | // This isn't the frame type we were expecting - we're done. 234 | if (packetType !== frameType) { 235 | return null 236 | } 237 | 238 | // Read the payload format. 239 | const payloadFormat = packet.readUInt8( 240 | UpdatePacketHeader.PAYLOAD_FORMAT 241 | ) 242 | 243 | // Check to see if we're compressed or not, and inflate if needed after skipping past the 8-byte header. 244 | const payload = packet.readUInt8(UpdatePacketHeader.DEFLATED) 245 | ? zlib.inflateSync(packet.slice(UPDATE_PACKET_HEADER_SIZE)) 246 | : packet.slice(UPDATE_PACKET_HEADER_SIZE) 247 | 248 | // If it's an action, it can only have one format. 249 | if (frameType === UpdatePacketType.ACTION) { 250 | return payloadFormat === UpdatePayloadType.JSON 251 | ? (JSON.parse( 252 | payload.toString() 253 | ) as ProtectNvrUpdateEventAction) 254 | : null 255 | } 256 | 257 | // Process the payload format accordingly. 258 | switch (payloadFormat) { 259 | case UpdatePayloadType.JSON: 260 | // If it's data payload, it can be anything. 261 | return JSON.parse(payload.toString()) as Record 262 | case UpdatePayloadType.STRING: 263 | return payload.toString('utf8') 264 | case UpdatePayloadType.BUFFER: 265 | return payload 266 | default: 267 | log.error( 268 | `Unknown payload packet type received in the realtime update events API: ${payloadFormat}.` 269 | ) 270 | return null 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/lib/cookieHelper.ts: -------------------------------------------------------------------------------- 1 | type CookieRaw = string 2 | type CookieObject = { [key: string]: string } 3 | 4 | export const cookieToObject = (raw: CookieRaw): CookieObject => { 5 | const cookies: { [key: string]: string } = {} 6 | 7 | raw.replace(/ /g, '') 8 | .split(';') 9 | .forEach((c) => { 10 | if (c.includes('=')) { 11 | const [key, value] = c.split('=') 12 | cookies[key] = value 13 | } else { 14 | cookies[c] = '' 15 | } 16 | }) 17 | 18 | return cookies 19 | } 20 | 21 | export const cookieToRaw = (cookie: CookieObject): CookieRaw => { 22 | let raw = '' 23 | 24 | Object.keys(cookie).forEach((key) => { 25 | const value = cookie[key] 26 | raw += `${key}=${value};` 27 | }) 28 | 29 | return raw 30 | } 31 | -------------------------------------------------------------------------------- /src/nodes/AccessController.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@nrchkb/logger' 2 | import Axios, { AxiosRequestConfig, AxiosResponse } from 'axios' 3 | import * as https from 'https' 4 | import { NodeAPI } from 'node-red' 5 | 6 | import { endpoints } from '../Endpoints' 7 | import { SharedProtectWebSocket } from '../SharedProtectWebSocket' 8 | import AccessControllerNodeConfigType from '../types/AccessControllerNodeConfigType' 9 | import AccessControllerNodeType from '../types/AccessControllerNodeType' 10 | import { Bootstrap } from '../types/Bootstrap' 11 | import { HttpError } from '../types/HttpError' 12 | import { UnifiResponse } from '../types/UnifiResponse' 13 | 14 | const { 15 | AbortController, 16 | } = require('abortcontroller-polyfill/dist/cjs-ponyfill') 17 | 18 | const bootstrapURI = '/proxy/protect/api/bootstrap' 19 | let hasProtect = true /* Lest assume at first */ 20 | 21 | const urlBuilder = (self: AccessControllerNodeType, endpoint?: string) => { 22 | return ( 23 | endpoints.protocol.base + 24 | self.config.controllerIp + 25 | (self.config.controllerPort?.trim().length 26 | ? `:${self.config.controllerPort}` 27 | : '') + 28 | endpoint 29 | ) 30 | } 31 | 32 | module.exports = (RED: NodeAPI) => { 33 | const body = function ( 34 | this: AccessControllerNodeType, 35 | config: AccessControllerNodeConfigType 36 | ) { 37 | const self = this 38 | const log = logger('UniFi', 'AccessController', self.name, self) 39 | 40 | RED.nodes.createNode(self, config) 41 | self.config = config 42 | 43 | self.initialized = false 44 | self.authenticated = false 45 | self.stopped = false 46 | self.controllerType = self.config.controllerType ?? 'UniFiOSConsole' 47 | self.abortController = new AbortController() 48 | 49 | // Register an Admin HTTP endpoint - so node config editors can obtain bootstraps (to obtain listings) 50 | RED.httpAdmin.get( 51 | `/nrchkb/unifi/bootsrap/${self.id}/`, 52 | RED.auth.needsPermission('flows.write'), 53 | (_req, res) => { 54 | if (self.bootstrapObject) { 55 | res.status(200).json(self.bootstrapObject) 56 | } else { 57 | // lets issue a 501 - Not Implemented for this host, given no Protect bootstrap was available 58 | res.status(501).end() 59 | } 60 | } 61 | ) 62 | // Remove HTTP Endpoint 63 | const removeBootstrapHTTPEndpoint = () => { 64 | const Check = (Route: any) => { 65 | if (Route.route === undefined) { 66 | return true 67 | } 68 | if ( 69 | !Route.route.path.startsWith( 70 | `/nrchkb/unifi/bootsrap/${self.id}` 71 | ) 72 | ) { 73 | return true 74 | } 75 | 76 | return false 77 | } 78 | RED.httpAdmin._router.stack = 79 | RED.httpAdmin._router.stack.filter(Check) 80 | } 81 | 82 | // The Boostrap request 83 | const getBootstrap = async (init?: boolean) => { 84 | if (hasProtect) { 85 | self.request(self.id, bootstrapURI, 'GET', undefined, 'json') 86 | .then((res: UnifiResponse) => { 87 | self.bootstrapObject = res as Bootstrap 88 | 89 | if (init) { 90 | // Fire up a shared websocket to the Protect WS endpoint 91 | self.protectSharedWS = new SharedProtectWebSocket( 92 | self, 93 | self.config, 94 | self.bootstrapObject 95 | ) 96 | } else { 97 | // Update the shared websocket to the Protect WS endpoint, so we can connect to its new lastUpdateId 98 | self.protectSharedWS?.updateLastUpdateId( 99 | self.bootstrapObject 100 | ) 101 | } 102 | }) 103 | .catch((error) => { 104 | hasProtect = false 105 | log.debug( 106 | `Received error when obtaining bootstrap: ${error}, assuming this is to be expected, i.e no protect instance.` 107 | ) 108 | }) 109 | } 110 | } 111 | 112 | const refresh = (init?: boolean) => { 113 | self.getAuthCookie(true) 114 | .catch((error) => { 115 | console.error(error) 116 | log.error('Failed to pre authenticate') 117 | }) 118 | .then(() => { 119 | if (init) { 120 | log.debug('Initialized') 121 | self.initialized = true 122 | log.debug('Successfully pre authenticated') 123 | } else { 124 | log.debug('Cookies refreshed') 125 | } 126 | // Fetch bootstrap (only for Protect) 127 | getBootstrap(init) 128 | }) 129 | } 130 | 131 | // Refresh cookies every 45 minutes 132 | const refreshTimeout = setInterval(() => { 133 | refresh() 134 | }, 2700000) 135 | 136 | self.getAuthCookie = (regenerate?: boolean) => { 137 | if (self.authCookie && regenerate !== true) { 138 | log.debug('Returning stored auth cookie') 139 | return Promise.resolve(self.authCookie) 140 | } 141 | 142 | const url = urlBuilder( 143 | self, 144 | endpoints[self.controllerType].login.url 145 | ) 146 | 147 | return new Promise((resolve) => { 148 | const authenticateWithRetry = () => { 149 | Axios.post( 150 | url, 151 | { 152 | username: self.credentials.username, 153 | password: self.credentials.password, 154 | }, 155 | { 156 | httpsAgent: new https.Agent({ 157 | rejectUnauthorized: false, 158 | keepAlive: true, 159 | }), 160 | signal: self.abortController.signal, 161 | } 162 | ) 163 | .then((response: AxiosResponse) => { 164 | if (response.status === 200) { 165 | self.authCookie = 166 | response.headers['set-cookie']?.[0] 167 | log.trace(`Cookie received: ${self.authCookie}`) 168 | 169 | self.authenticated = true 170 | resolve(self.authCookie) 171 | } 172 | }) 173 | .catch((reason: any) => { 174 | if (reason?.name === 'AbortError') { 175 | log.error('Request Aborted') 176 | } 177 | 178 | self.authenticated = false 179 | self.authCookie = undefined 180 | 181 | if (!self.stopped) { 182 | setTimeout( 183 | authenticateWithRetry, 184 | endpoints[self.controllerType].login.retry 185 | ) 186 | } 187 | }) 188 | } 189 | 190 | authenticateWithRetry() 191 | }) 192 | } 193 | 194 | self.request = async (nodeId, endpoint, method, data, responseType) => { 195 | if (!endpoint) { 196 | Promise.reject(new Error('endpoint cannot be empty!')) 197 | } 198 | 199 | if (!method) { 200 | Promise.reject(new Error('method cannot be empty!')) 201 | } 202 | 203 | const url = urlBuilder(self, endpoint) 204 | 205 | return new Promise((resolve, reject) => { 206 | const axiosRequest = async () => { 207 | const Config: AxiosRequestConfig = { 208 | url, 209 | method, 210 | data, 211 | httpsAgent: new https.Agent({ 212 | rejectUnauthorized: false, 213 | keepAlive: true, 214 | }), 215 | headers: { 216 | cookie: (await self.getAuthCookie()) ?? '', 217 | 'Content-Type': 'application/json', 218 | 'Accept-Encoding': 'gzip, deflate, br', 219 | Accept: 'application/json', 220 | 'X-Request-ID': nodeId, 221 | }, 222 | withCredentials: true, 223 | responseType, 224 | } 225 | 226 | Axios.request(Config) 227 | .catch((error) => { 228 | if (error instanceof HttpError) { 229 | if (error.status === 401) { 230 | self.authenticated = false 231 | self.authCookie = undefined 232 | setTimeout( 233 | axiosRequest, 234 | endpoints[self.controllerType].login 235 | .retry 236 | ) 237 | } 238 | } 239 | 240 | reject(error) 241 | }) 242 | .then((response) => { 243 | if (response) { 244 | resolve(response.data) 245 | } 246 | }) 247 | } 248 | axiosRequest() 249 | }) 250 | } 251 | 252 | self.on('close', (_: boolean, done: () => void) => { 253 | self.stopped = true 254 | clearTimeout(refreshTimeout) 255 | removeBootstrapHTTPEndpoint() 256 | self.protectSharedWS?.shutdown() 257 | self.abortController.abort() 258 | 259 | const logout = async () => { 260 | const url = urlBuilder( 261 | self, 262 | endpoints[self.controllerType].logout.url 263 | ) 264 | 265 | Axios.post( 266 | url, 267 | {}, 268 | { 269 | httpsAgent: new https.Agent({ 270 | rejectUnauthorized: false, 271 | keepAlive: true, 272 | }), 273 | headers: { 274 | cookie: (await self.getAuthCookie()) ?? '', 275 | }, 276 | } 277 | ) 278 | .catch((error) => { 279 | console.error(error) 280 | log.error('Failed to log out') 281 | done() 282 | }) 283 | .then(() => { 284 | log.trace('Successfully logged out') 285 | done() 286 | }) 287 | } 288 | 289 | logout() 290 | }) 291 | 292 | // Initial cookies fetch 293 | refresh(true) 294 | } 295 | 296 | RED.nodes.registerType('unifi-access-controller', body, { 297 | credentials: { 298 | username: { type: 'text' }, 299 | password: { type: 'password' }, 300 | }, 301 | }) 302 | 303 | logger('UniFi', 'AccessController').debug('Type registered') 304 | } 305 | -------------------------------------------------------------------------------- /src/nodes/Protect.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@nrchkb/logger' 2 | import { isMatch } from 'lodash' 3 | import { NodeAPI } from 'node-red' 4 | import util from 'util' 5 | 6 | import EventModels, { CameraIDLocation, ThumbnailSupport } from '../EventModels' 7 | import { Interest, SocketStatus } from '../SharedProtectWebSocket' 8 | import AccessControllerNodeType from '../types/AccessControllerNodeType' 9 | import { Camera } from '../types/Bootstrap' 10 | import ProtectNodeConfigType from '../types/ProtectNodeConfigType' 11 | import ProtectNodeType from '../types/ProtectNodeType' 12 | 13 | module.exports = (RED: NodeAPI) => { 14 | const reqRootPath = '/proxy/protect/api' 15 | const getReqPath = (Type: string, ID: string) => { 16 | return `${reqRootPath}/${Type}/${ID}` 17 | } 18 | 19 | const init = function ( 20 | this: ProtectNodeType, 21 | config: ProtectNodeConfigType 22 | ) { 23 | const self = this 24 | RED.nodes.createNode(self, config) 25 | self.config = config 26 | 27 | self.accessControllerNode = RED.nodes.getNode( 28 | self.config.accessControllerNodeId 29 | ) as AccessControllerNodeType 30 | 31 | if (!self.accessControllerNode) { 32 | self.status({ 33 | fill: 'red', 34 | shape: 'dot', 35 | text: 'Access Controller not found / or configured', 36 | }) 37 | return 38 | } 39 | 40 | self.name = 41 | self.config.name || self.accessControllerNode.name + ':' + self.id 42 | 43 | new Promise((resolve) => { 44 | const checkAndWait = () => { 45 | if (self.accessControllerNode.initialized) { 46 | resolve(true) 47 | } else { 48 | self.status({ 49 | fill: 'grey', 50 | shape: 'dot', 51 | text: 'Initializing...', 52 | }) 53 | 54 | setTimeout(checkAndWait, 1500) 55 | } 56 | } 57 | 58 | checkAndWait() 59 | }).then(() => { 60 | self.status({ 61 | fill: 'green', 62 | shape: 'dot', 63 | text: 'Connected', 64 | }) 65 | body.call(self) 66 | }) 67 | } 68 | 69 | const body = function (this: ProtectNodeType) { 70 | const self = this 71 | const log = logger('UniFi', 'Protect', self.name, self) 72 | 73 | // Used to store the Start of an event with a duration. 74 | const startEvents: any = {} 75 | 76 | self.on('close', (_: boolean, done: () => void) => { 77 | self.accessControllerNode.protectSharedWS?.deregisterInterest( 78 | self.id 79 | ) 80 | done() 81 | }) 82 | 83 | self.on('input', (msg) => { 84 | log.debug('Received input message: ' + util.inspect(msg)) 85 | if (msg.topic) { 86 | const Path = getReqPath('cameras', msg.topic) 87 | 88 | self.status({ 89 | fill: 'grey', 90 | shape: 'dot', 91 | text: 'Sending...', 92 | }) 93 | 94 | self.accessControllerNode 95 | .request(self.id, Path, 'PATCH', msg.payload, 'json') 96 | .then((data) => { 97 | self.status({ 98 | fill: 'green', 99 | shape: 'dot', 100 | text: 'Sent', 101 | }) 102 | log.debug('Result:') 103 | log.trace(util.inspect(data)) 104 | 105 | self.send([{ payload: data, inputMsg: msg }, undefined]) 106 | }) 107 | .catch((error) => { 108 | log.error(error) 109 | 110 | self.status({ 111 | fill: 'red', 112 | shape: 'dot', 113 | text: error.message, 114 | }) 115 | }) 116 | } 117 | }) 118 | 119 | self.status({ 120 | fill: 'green', 121 | shape: 'dot', 122 | text: 'Initialized', 123 | }) 124 | 125 | // Awaiter (Node RED 3.1 evaluateJSONataExpression ) 126 | let _AwaiterResolver: (value?: unknown) => void 127 | const Awaiter = () => { 128 | return new Promise((Resolve) => { 129 | _AwaiterResolver = Resolve 130 | }) 131 | } 132 | 133 | // Register our interest in Protect Updates. 134 | const handleUpdate = async (data: any) => { 135 | // Debug ? 136 | if (self.config.debug) { 137 | self.send([undefined, { payload: data }]) 138 | } 139 | 140 | // Get ID 141 | const eventId = data.action.id 142 | 143 | // Date 144 | const Now = new Date().getTime() 145 | 146 | // Check if we are expecting an end 147 | const startEvent = startEvents[eventId] 148 | 149 | if (startEvent) { 150 | // Is this an end only event 151 | const onEnd = 152 | startEvent.payload._profile.startMetadata.sendOnEnd === true 153 | if (!onEnd) { 154 | startEvent.payload.timestamps.endDate = 155 | data.payload.end || Now 156 | startEvent.payload.eventStatus = 'EndOfEvent' 157 | } else { 158 | startEvent.payload.timestamps = { 159 | eventDate: data.payload.end || Now, 160 | } 161 | } 162 | 163 | // has End Metadata 164 | const hasMeta = 165 | startEvent.payload._profile.endMetadata !== undefined 166 | if (hasMeta) { 167 | if ( 168 | startEvent.payload._profile.endMetadata 169 | .valueExpression !== undefined 170 | ) { 171 | const Waiter = Awaiter() 172 | const EXP = RED.util.prepareJSONataExpression( 173 | startEvent.payload._profile.endMetadata 174 | .valueExpression, 175 | self 176 | ) 177 | RED.util.evaluateJSONataExpression( 178 | EXP, 179 | { _startData: startEvent, ...data }, 180 | (_err, res) => { 181 | startEvent.payload.value = res 182 | _AwaiterResolver() 183 | } 184 | ) 185 | 186 | await Promise.all([Waiter]) 187 | } 188 | 189 | if ( 190 | startEvent.payload._profile.endMetadata.label !== 191 | undefined 192 | ) { 193 | startEvent.payload.event = 194 | startEvent.payload._profile.endMetadata.label 195 | } 196 | } 197 | 198 | const EventThumbnailSupport: ThumbnailSupport | undefined = 199 | startEvent.payload._profile.startMetadata.thumbnailSupport 200 | 201 | switch (EventThumbnailSupport) { 202 | case ThumbnailSupport.START_END: 203 | startEvent.payload.snapshot = { 204 | availability: 'NOW', 205 | uri: `/proxy/protect/api/events/${eventId}/thumbnail`, 206 | } 207 | break 208 | case ThumbnailSupport.START_WITH_DELAYED_END: 209 | startEvent.payload.snapshot = { 210 | availability: 'WITH_DELAY', 211 | uri: `/proxy/protect/api/events/${eventId}/thumbnail`, 212 | } 213 | break 214 | } 215 | 216 | delete startEvent.payload._profile 217 | delete startEvent.payload.expectEndEvent 218 | self.send([RED.util.cloneMessage(startEvent), undefined]) 219 | delete startEvents[eventId] 220 | } else { 221 | let Camera: Camera | undefined 222 | 223 | const Cams: string[] = self.config.cameraIds?.split(',') || [] 224 | 225 | const identifiedEvent = EventModels.find((eventModel) => 226 | isMatch(data, eventModel.shapeProfile) 227 | ) 228 | 229 | if (!identifiedEvent || !identifiedEvent.startMetadata.id) { 230 | return 231 | } 232 | 233 | switch (identifiedEvent.startMetadata.idLocation) { 234 | case CameraIDLocation.ACTION_ID: 235 | if (!Cams.includes(data.action.id)) { 236 | return 237 | } 238 | Camera = 239 | self.accessControllerNode.bootstrapObject?.cameras?.find( 240 | (c) => c.id === data.action.id 241 | ) 242 | break 243 | 244 | case CameraIDLocation.PAYLOAD_CAMERA: 245 | if (!Cams.includes(data.payload.camera)) { 246 | return 247 | } 248 | Camera = 249 | self.accessControllerNode.bootstrapObject?.cameras?.find( 250 | (c) => c.id === data.payload.camera 251 | ) 252 | break 253 | 254 | case CameraIDLocation.ACTION_RECORDID: 255 | if (!Cams.includes(data.action.recordId)) { 256 | return 257 | } 258 | Camera = 259 | self.accessControllerNode.bootstrapObject?.cameras?.find( 260 | (c) => c.id === data.action.recordId 261 | ) 262 | break 263 | } 264 | 265 | if (!Camera) { 266 | return 267 | } 268 | 269 | const hasEnd = 270 | identifiedEvent.startMetadata.hasMultiple === true 271 | const onEnd = identifiedEvent.startMetadata.sendOnEnd === true 272 | 273 | const EVIDsArray: string[] = 274 | self.config.eventIds?.split(',') || [] 275 | 276 | const matchedEvent = EVIDsArray.includes( 277 | identifiedEvent.startMetadata.id 278 | ) 279 | 280 | if (!matchedEvent) { 281 | return 282 | } 283 | 284 | const UserPL: any = { 285 | payload: { 286 | event: identifiedEvent.startMetadata.label, 287 | eventId: eventId, 288 | cameraName: Camera.name, 289 | cameraType: Camera.type, 290 | cameraId: Camera.id, 291 | expectEndEvent: hasEnd && !onEnd, 292 | }, 293 | } 294 | 295 | const EventThumbnailSupport: ThumbnailSupport | undefined = 296 | identifiedEvent.startMetadata.thumbnailSupport 297 | 298 | switch (EventThumbnailSupport) { 299 | case ThumbnailSupport.SINGLE: 300 | case ThumbnailSupport.START_END: 301 | case ThumbnailSupport.START_WITH_DELAYED_END: 302 | UserPL.payload.snapshot = { 303 | availability: 'NOW', 304 | uri: `/proxy/protect/api/events/${eventId}/thumbnail`, 305 | } 306 | break 307 | case ThumbnailSupport.SINGLE_DELAYED: 308 | UserPL.payload.snapshot = { 309 | availability: 'WITH_DELAY', 310 | uri: `/proxy/protect/api/events/${eventId}/thumbnail`, 311 | } 312 | break 313 | } 314 | 315 | if (identifiedEvent.startMetadata.valueExpression) { 316 | const Waiter = Awaiter() 317 | const EXP = RED.util.prepareJSONataExpression( 318 | identifiedEvent.startMetadata.valueExpression, 319 | self 320 | ) 321 | RED.util.evaluateJSONataExpression( 322 | EXP, 323 | data, 324 | (_err, res) => { 325 | UserPL.payload.value = res 326 | _AwaiterResolver() 327 | } 328 | ) 329 | 330 | await Promise.all([Waiter]) 331 | } 332 | 333 | UserPL.payload.originalEventData = data 334 | UserPL.topic = UserPL.payload.cameraName 335 | 336 | if (hasEnd && !onEnd) { 337 | UserPL.payload.eventStatus = 'StartOfEvent' 338 | UserPL.payload.timestamps = { 339 | startDate: data.payload.start || Now, 340 | } 341 | self.send([UserPL, undefined]) 342 | startEvents[eventId] = RED.util.cloneMessage(UserPL) 343 | startEvents[eventId].payload._profile = identifiedEvent 344 | } 345 | 346 | if (hasEnd && onEnd) { 347 | UserPL.payload._profile = identifiedEvent 348 | startEvents[eventId] = UserPL 349 | } 350 | 351 | if (!hasEnd) { 352 | UserPL.payload.timestamps = { 353 | eventDate: data.payload.start || Now, 354 | } 355 | self.send([UserPL, undefined]) 356 | } 357 | } 358 | } 359 | 360 | const statusCallback = (Status: SocketStatus) => { 361 | switch (Status) { 362 | case SocketStatus.UNKNOWN: 363 | self.status({ 364 | fill: 'grey', 365 | shape: 'dot', 366 | text: 'Unknown', 367 | }) 368 | break 369 | 370 | case SocketStatus.CONNECTION_ERROR: 371 | self.status({ 372 | fill: 'red', 373 | shape: 'dot', 374 | text: 'Connection error', 375 | }) 376 | break 377 | 378 | case SocketStatus.CONNECTED: 379 | self.status({ 380 | fill: 'green', 381 | shape: 'dot', 382 | text: 'Connected', 383 | }) 384 | break 385 | 386 | case SocketStatus.RECOVERING_CONNECTION: 387 | self.status({ 388 | fill: 'yellow', 389 | shape: 'dot', 390 | text: 'Recovering connection...', 391 | }) 392 | break 393 | 394 | case SocketStatus.HEARTBEAT: 395 | self.status({ 396 | fill: 'yellow', 397 | shape: 'dot', 398 | text: 'Sending heartbeat...', 399 | }) 400 | break 401 | } 402 | } 403 | 404 | const I: Interest = { 405 | dataCallback: handleUpdate, 406 | statusCallback: statusCallback, 407 | } 408 | const Status = 409 | self.accessControllerNode.protectSharedWS?.registerInterest( 410 | self.id, 411 | I 412 | ) 413 | if (Status !== undefined) { 414 | statusCallback(Status) 415 | } 416 | 417 | log.debug('Initialized') 418 | } 419 | 420 | // Register the Protect Node 421 | RED.nodes.registerType('unifi-protect', init) 422 | 423 | logger('UniFi', 'Protect').debug('Type registered') 424 | } 425 | -------------------------------------------------------------------------------- /src/nodes/Request.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@nrchkb/logger' 2 | import { NodeAPI } from 'node-red' 3 | import util from 'util' 4 | 5 | import AccessControllerNodeType from '../types/AccessControllerNodeType' 6 | import RequestNodeConfigType from '../types/RequestNodeConfigType' 7 | import RequestNodeInputPayloadType from '../types/RequestNodeInputPayloadType' 8 | import RequestNodeType from '../types/RequestNodeType' 9 | import { UnifiResponse } from '../types/UnifiResponse' 10 | 11 | module.exports = (RED: NodeAPI) => { 12 | const validateInputPayload = ( 13 | self: RequestNodeType, 14 | payload: any 15 | ): RequestNodeInputPayloadType => { 16 | if (!self.config?.endpoint && !payload?.endpoint) { 17 | self.status({ 18 | fill: 'red', 19 | shape: 'dot', 20 | text: 'Missing endpoint', 21 | }) 22 | 23 | throw new Error('Missing endpoint in either payload or node config') 24 | } 25 | 26 | return payload 27 | } 28 | 29 | const init = function ( 30 | this: RequestNodeType, 31 | config: RequestNodeConfigType 32 | ) { 33 | const self = this 34 | RED.nodes.createNode(self, config) 35 | self.config = config 36 | 37 | self.accessControllerNode = RED.nodes.getNode( 38 | self.config.accessControllerNodeId 39 | ) as AccessControllerNodeType 40 | 41 | if (!self.accessControllerNode) { 42 | self.status({ 43 | fill: 'red', 44 | shape: 'dot', 45 | text: 'Access Controller not found', 46 | }) 47 | return 48 | } 49 | 50 | self.name = 51 | self.config.name || self.accessControllerNode.name + ':' + self.id 52 | 53 | new Promise((resolve) => { 54 | const checkAndWait = () => { 55 | if (self.accessControllerNode.initialized) { 56 | resolve(true) 57 | } else { 58 | self.status({ 59 | fill: 'yellow', 60 | shape: 'dot', 61 | text: 'Initializing...', 62 | }) 63 | 64 | setTimeout(checkAndWait, 1500) 65 | } 66 | } 67 | 68 | checkAndWait() 69 | }).then(() => { 70 | body.call(self) 71 | }) 72 | } 73 | 74 | const body = function (this: RequestNodeType) { 75 | const self = this 76 | const log = logger('UniFi', 'Request', self.name, self) 77 | 78 | self.on('input', (msg) => { 79 | log.debug('Received input message: ' + util.inspect(msg)) 80 | 81 | self.status({ 82 | fill: 'grey', 83 | shape: 'dot', 84 | text: 'Sending', 85 | }) 86 | 87 | const inputPayload = validateInputPayload(self, msg.payload) 88 | 89 | const endpoint = inputPayload?.endpoint || self.config.endpoint 90 | const method = inputPayload?.method || self.config.method || 'GET' 91 | const responseType = 92 | inputPayload?.responseType || self.config.responseType || 'json' 93 | 94 | let data = undefined 95 | if (method != 'GET') { 96 | data = inputPayload?.data || self.config.data 97 | } 98 | 99 | self.accessControllerNode 100 | .request(self.id, endpoint, method, data, responseType) 101 | .then((data) => { 102 | self.status({ 103 | fill: 'green', 104 | shape: 'dot', 105 | text: 'Sent', 106 | }) 107 | log.debug('Result:') 108 | log.trace(util.inspect(data)) 109 | 110 | console.log(typeof data) 111 | 112 | const _send = (Result: UnifiResponse) => { 113 | self.send({ 114 | payload: Result, 115 | inputMsg: msg, 116 | }) 117 | } 118 | 119 | if (!Buffer.isBuffer(data) && typeof data !== 'string') { 120 | _send(data) 121 | } 122 | }) 123 | .catch((error) => { 124 | log.error(error) 125 | 126 | self.status({ 127 | fill: 'red', 128 | shape: 'dot', 129 | text: error.message, 130 | }) 131 | }) 132 | }) 133 | 134 | self.status({ 135 | fill: 'green', 136 | shape: 'dot', 137 | text: 'Initialized', 138 | }) 139 | 140 | log.debug('Initialized') 141 | } 142 | 143 | // Register the requestHTTP node 144 | RED.nodes.registerType('unifi-request', init) 145 | 146 | logger('UniFi', 'Request').debug('Type registered') 147 | } 148 | -------------------------------------------------------------------------------- /src/nodes/WebSocket.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '@nrchkb/logger' 2 | import { Loggers } from '@nrchkb/logger/src/types' 3 | import * as crypto from 'crypto' 4 | import { NodeAPI } from 'node-red' 5 | import util from 'util' 6 | import WebSocket from 'ws' 7 | 8 | import { endpoints } from '../Endpoints' 9 | import { ProtectApiUpdates } from '../lib/ProtectApiUpdates' 10 | import AccessControllerNodeType from '../types/AccessControllerNodeType' 11 | import WebSocketNodeConfigType from '../types/WebSocketNodeConfigType' 12 | import WebSocketNodeInputPayloadType from '../types/WebSocketNodeInputPayloadType' 13 | import WebSocketNodeType from '../types/WebSocketNodeType' 14 | 15 | /** 16 | * DEFAULT_RECONNECT_TIMEOUT is to wait until next try to connect web socket in case of error or server side closed socket (for example UniFi restart) 17 | */ 18 | const DEFAULT_RECONNECT_TIMEOUT = 90000 19 | 20 | module.exports = (RED: NodeAPI) => { 21 | const validateInputPayload = ( 22 | self: WebSocketNodeType, 23 | payload: any 24 | ): T => { 25 | if (!self.config?.endpoint && !payload?.endpoint) { 26 | self.status({ 27 | fill: 'red', 28 | shape: 'dot', 29 | text: 'Missing endpoint', 30 | }) 31 | 32 | throw new Error('Missing endpoint in either payload or node config') 33 | } 34 | 35 | return payload 36 | } 37 | 38 | const stopWebsocket = async ( 39 | self: WebSocketNodeType, 40 | log: Loggers, 41 | action: string, 42 | callback: () => void 43 | ): Promise => { 44 | if (self.ws) { 45 | self.ws.removeAllListeners() 46 | self.ws.close(1000, `Node ${action}`) 47 | self.ws.terminate() 48 | log.debug(`ws ${self.ws?.['id']} closed`) 49 | self.ws = undefined 50 | } else { 51 | log.debug('ws already closed') 52 | } 53 | 54 | callback() 55 | } 56 | 57 | const setupWebsocket = async (self: WebSocketNodeType): Promise => { 58 | const connectWebSocket = async () => { 59 | const wsPort = 60 | self.accessControllerNode.config.wsPort || 61 | endpoints[self.accessControllerNode.controllerType].wsport 62 | const url = `${endpoints.protocol.webSocket}${self.accessControllerNode.config.controllerIp}:${wsPort}${self.endpoint}` 63 | 64 | const id = crypto.randomBytes(16).toString('hex') 65 | const wsLogger = logger('UniFi', `WebSocket:${id}`, self.name, self) 66 | 67 | self.ws = new WebSocket(url, { 68 | rejectUnauthorized: false, 69 | headers: { 70 | Cookie: await self.accessControllerNode 71 | .getAuthCookie() 72 | .then((value) => value), 73 | }, 74 | }) 75 | 76 | self.ws.id = id 77 | 78 | if ( 79 | !self.ws || 80 | self.ws.readyState === WebSocket.CLOSING || 81 | self.ws.readyState === WebSocket.CLOSED 82 | ) { 83 | wsLogger.trace( 84 | `Unable to connect to UniFi on ${url}. Will retry again later.` 85 | ) 86 | 87 | self.status({ 88 | fill: 'yellow', 89 | shape: 'dot', 90 | text: 'Connecting...', 91 | }) 92 | 93 | setTimeout( 94 | connectWebSocket, 95 | self.config.reconnectTimeout ?? DEFAULT_RECONNECT_TIMEOUT 96 | ) 97 | } else { 98 | self.ws.on('open', function open() { 99 | wsLogger.debug(`Connection to ${url} open`) 100 | 101 | self.status({ 102 | fill: 'green', 103 | shape: 'dot', 104 | text: 'Connection open', 105 | }) 106 | }) 107 | 108 | let tick = false 109 | self.ws.on('message', (data) => { 110 | wsLogger.trace('Received data') 111 | 112 | try { 113 | const parsedData = JSON.parse(data.toString()) 114 | 115 | self.send({ 116 | payload: parsedData, 117 | }) 118 | } catch (_) { 119 | // Let's try to decode packet 120 | try { 121 | const protectApiUpdate = 122 | ProtectApiUpdates.decodeUpdatePacket( 123 | wsLogger, 124 | data as Buffer 125 | ) 126 | 127 | self.send({ 128 | payload: protectApiUpdate, 129 | }) 130 | } catch (error: any) { 131 | wsLogger.error(error) 132 | } 133 | } 134 | 135 | if (tick) { 136 | self.status({ 137 | fill: 'blue', 138 | shape: 'ring', 139 | text: 'Receiving data', 140 | }) 141 | } else { 142 | self.status({ 143 | fill: 'grey', 144 | shape: 'ring', 145 | text: 'Receiving data', 146 | }) 147 | } 148 | 149 | tick = !tick 150 | }) 151 | 152 | self.ws.on('error', (error) => { 153 | wsLogger.error(`${error}`) 154 | 155 | self.status({ 156 | fill: 'red', 157 | shape: 'dot', 158 | text: 'Error occurred', 159 | }) 160 | }) 161 | 162 | self.ws.on('close', (code, reason) => { 163 | wsLogger.debug( 164 | `Connection to ${url} closed. Code:${code}${ 165 | reason ? `, reason: ${reason}` : '' 166 | }` 167 | ) 168 | 169 | self.send([ 170 | {}, 171 | { 172 | payload: { 173 | code, 174 | reason, 175 | url, 176 | }, 177 | }, 178 | ]) 179 | 180 | self.status({ 181 | fill: 'yellow', 182 | shape: 'dot', 183 | text: `Connection closed. Code:${code}`, 184 | }) 185 | 186 | if (code === 1000) { 187 | wsLogger.trace( 188 | 'Connection possibly closed by node itself' 189 | ) 190 | } else { 191 | if (code === 1006) { 192 | wsLogger.error('Is UniFi server down?', false) 193 | } 194 | 195 | setTimeout( 196 | connectWebSocket, 197 | self.config.reconnectTimeout ?? 198 | DEFAULT_RECONNECT_TIMEOUT 199 | ) 200 | } 201 | }) 202 | 203 | self.ws.on('unexpected-response', (request, response) => { 204 | wsLogger.error('unexpected-response from the server') 205 | try { 206 | wsLogger.error(util.inspect(request)) 207 | wsLogger.error(util.inspect(response)) 208 | } catch (error: any) { 209 | wsLogger.error(error) 210 | } 211 | }) 212 | } 213 | } 214 | 215 | await connectWebSocket() 216 | } 217 | 218 | const init = function ( 219 | this: WebSocketNodeType, 220 | config: WebSocketNodeConfigType 221 | ) { 222 | const self = this 223 | RED.nodes.createNode(self, config) 224 | self.config = config 225 | 226 | self.accessControllerNode = RED.nodes.getNode( 227 | self.config.accessControllerNodeId 228 | ) as AccessControllerNodeType 229 | 230 | if (!self.accessControllerNode) { 231 | self.status({ 232 | fill: 'red', 233 | shape: 'dot', 234 | text: 'Access Controller not found', 235 | }) 236 | return 237 | } 238 | 239 | self.name = 240 | self.config.name || self.accessControllerNode.name + ':' + self.id 241 | 242 | new Promise((resolve) => { 243 | const checkAndWait = () => { 244 | if (self.accessControllerNode.initialized) { 245 | resolve(true) 246 | } else { 247 | self.status({ 248 | fill: 'yellow', 249 | shape: 'dot', 250 | text: 'Initializing...', 251 | }) 252 | 253 | setTimeout(checkAndWait, 1500) 254 | } 255 | } 256 | 257 | checkAndWait() 258 | }).then(async () => { 259 | await body.call(self) 260 | }) 261 | } 262 | 263 | const body = async function (this: WebSocketNodeType) { 264 | const self = this 265 | const log = logger('UniFi', 'WebSocket', self.name, self) 266 | 267 | self.endpoint = self.config.endpoint 268 | await setupWebsocket(self) 269 | 270 | self.on('input', async (msg) => { 271 | log.debug('Received input message: ' + util.inspect(msg)) 272 | 273 | const inputPayload = 274 | validateInputPayload( 275 | self, 276 | msg.payload 277 | ) 278 | 279 | const newEndpoint = inputPayload.endpoint ?? self.config.endpoint 280 | 281 | if (newEndpoint?.trim().length) { 282 | if (self.endpoint != newEndpoint) { 283 | self.endpoint = newEndpoint 284 | 285 | await stopWebsocket(self, log, 'reconfigured', () => 286 | setupWebsocket(self) 287 | ) 288 | } else { 289 | log.debug( 290 | `Input ignored, endpoint did not change: ${self.endpoint}, ${inputPayload.endpoint}, ${self.config.endpoint}` 291 | ) 292 | } 293 | } else { 294 | log.debug( 295 | `Input ignored, new endpoint is empty: ${self.endpoint}, ${inputPayload.endpoint}, ${self.config.endpoint}` 296 | ) 297 | } 298 | }) 299 | 300 | self.on('close', (removed: boolean, done: () => void) => { 301 | const cleanup = async () => { 302 | self.status({ 303 | fill: 'grey', 304 | shape: 'dot', 305 | text: 'Disconnecting', 306 | }) 307 | 308 | log.debug( 309 | `Disconnecting - node ${removed ? 'removed' : 'restarted'}` 310 | ) 311 | 312 | await stopWebsocket( 313 | self, 314 | log, 315 | `${removed ? 'removed' : 'restarted'}`, 316 | done 317 | ) 318 | } 319 | 320 | cleanup() 321 | }) 322 | 323 | if (self.endpoint?.trim().length && !!self.ws) { 324 | await setupWebsocket(self) 325 | } 326 | 327 | self.status({ 328 | fill: 'green', 329 | shape: 'dot', 330 | text: 'Initialized', 331 | }) 332 | 333 | log.debug('Initialized') 334 | } 335 | 336 | // Register the requestHTTP node 337 | RED.nodes.registerType('unifi-web-socket', init) 338 | 339 | logger('UniFi', 'WebSocket').debug('Type registered') 340 | } 341 | -------------------------------------------------------------------------------- /src/nodes/unifi.ts: -------------------------------------------------------------------------------- 1 | import { logger, loggerSetup } from '@nrchkb/logger' 2 | import Axios, { AxiosHeaders } from 'axios' 3 | import { NodeAPI } from 'node-red' 4 | import * as util from 'util' 5 | 6 | import { cookieToObject } from '../lib/cookieHelper' 7 | import { HttpError } from '../types/HttpError' 8 | import { UnifiResponse, UnifiResponseMetaMsg } from '../types/UnifiResponse' 9 | 10 | loggerSetup({ timestampEnabled: 'UniFi' }) 11 | 12 | module.exports = (RED: NodeAPI) => { 13 | const log = logger('UniFi') 14 | 15 | Axios.interceptors.request.use( 16 | (config) => { 17 | log.debug(`Sending request to: ${config.url}`) 18 | 19 | if (config.headers) { 20 | const headers = config.headers as AxiosHeaders 21 | if ( 22 | headers.get('cookie') && 23 | config.method?.toLowerCase() !== 'get' 24 | ) { 25 | // Create x-csrf-token 26 | const composedCookie = cookieToObject( 27 | headers.get('cookie') as string 28 | ) 29 | 30 | if ('TOKEN' in composedCookie) { 31 | const [, jwtEncodedBody] = 32 | composedCookie['TOKEN'].split('.') 33 | 34 | if (jwtEncodedBody) { 35 | const buffer = Buffer.from(jwtEncodedBody, 'base64') 36 | const { csrfToken } = JSON.parse( 37 | buffer.toString('ascii') 38 | ) 39 | 40 | if (csrfToken) { 41 | headers.set('x-csrf-token', csrfToken) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | log.trace(util.inspect(config)) 49 | return config 50 | }, 51 | function (error) { 52 | log.error(`Failed to send request due to: ${error}`) 53 | return Promise.reject(error) 54 | } 55 | ) 56 | 57 | Axios.interceptors.response.use( 58 | (response) => { 59 | log.debug(`Successful response from: ${response.config.url}`) 60 | log.trace(util.inspect(response)) 61 | return response 62 | }, 63 | function (error: any) { 64 | if (Axios.isCancel(error)) { 65 | log.trace(`Request cancelled: ${error.message}`) 66 | return Promise.reject(error) 67 | } 68 | 69 | const nodeId = error?.response?.config?.headers?.['X-Request-ID'] 70 | const relatedNode = RED.nodes.getNode(nodeId) 71 | 72 | const unifiResponse = error?.response?.data as UnifiResponse 73 | 74 | log.error( 75 | `Bad response from: ${ 76 | error?.response?.config?.url ?? error?.config?.url 77 | }`, 78 | true, 79 | relatedNode 80 | ) 81 | log.trace(util.inspect(error?.response)) 82 | 83 | if (error?.code === 'ETIMEDOUT') { 84 | const msg = 'Connect ETIMEDOUT' 85 | return Promise.reject(new Error(msg)) 86 | } 87 | 88 | switch (error?.response?.status) { 89 | case 400: 90 | if ( 91 | unifiResponse?.meta?.msg == 92 | UnifiResponseMetaMsg.INVALID_PAYLOAD 93 | ) { 94 | const msg = `Invalid Payload ${unifiResponse?.meta?.validationError?.field} ${unifiResponse?.meta?.validationError?.pattern}` 95 | log.error(msg) 96 | return Promise.reject(new Error(msg)) 97 | } 98 | 99 | log.error('Invalid Payload: ' + error, true, relatedNode) 100 | throw new HttpError('Invalid Payload', 403) 101 | case 401: 102 | if ( 103 | unifiResponse?.meta?.msg == 104 | UnifiResponseMetaMsg.NO_SITE_CONTEXT 105 | ) { 106 | log.error('No Site Context') 107 | return Promise.reject(new Error('No Site Context')) 108 | } 109 | 110 | log.error('Unauthorized: ' + error, true, relatedNode) 111 | return Promise.reject(new HttpError('Unauthorized', 401)) 112 | case 403: 113 | log.error('Forbidden access: ' + error, true, relatedNode) 114 | return Promise.reject( 115 | new HttpError('Forbidden access', 403) 116 | ) 117 | case 404: 118 | log.error('Endpoint not found: ' + error, true, relatedNode) 119 | return Promise.reject( 120 | new HttpError('Endpoint not found', 404) 121 | ) 122 | } 123 | 124 | log.trace(util.inspect(error)) 125 | return Promise.reject(error) 126 | } 127 | ) 128 | 129 | log.debug('Initialized') 130 | } 131 | -------------------------------------------------------------------------------- /src/test/main.test.ts: -------------------------------------------------------------------------------- 1 | import 'should' 2 | 3 | import { afterEach, beforeEach, describe, it } from 'mocha' 4 | 5 | const helper = require('node-red-node-test-helper') 6 | 7 | const unifi = require('../nodes/unifi') 8 | const unifiRequestNode = require('../nodes/Request') 9 | const unifiAccessControllerNode = require('../nodes/AccessController') 10 | const unifiProtectNode = require('../nodes/Protect') 11 | 12 | const nock = require('nock') 13 | nock('https://localhost') 14 | .persist() 15 | .post('/api/auth/login') 16 | .reply(200, 'Ok', { 'set-cookie': ['COOKIE'] }) 17 | nock('https://localhost').persist().post('/api/logout').reply(200) 18 | nock('https://localhost').persist().get('/test').reply(200) 19 | 20 | helper.init(require.resolve('node-red')) 21 | 22 | describe('UniFi Node', function () { 23 | this.timeout(30000) 24 | 25 | beforeEach(function (done) { 26 | helper.startServer(done) 27 | }) 28 | 29 | afterEach(function (done) { 30 | helper.unload() 31 | helper.stopServer(done) 32 | }) 33 | 34 | let AC1 35 | let R1 36 | let P1; 37 | 38 | 39 | it('Initialize', function (done) { 40 | helper 41 | .load( 42 | [unifi, unifiAccessControllerNode, unifiRequestNode, unifiProtectNode], 43 | [ 44 | { 45 | id: 'ac1', 46 | type: 'unifi-access-controller', 47 | name: 'UDM Pro', 48 | controllerIp: 'localhost', 49 | }, 50 | { 51 | id: 'r1', 52 | type: 'unifi-request', 53 | name: 'UDM Pro Requester', 54 | endpoint: '/test', 55 | accessControllerNodeId: 'ac1', 56 | }, 57 | { 58 | id: 'p1', 59 | type: 'unifi-protect', 60 | name: 'Protect', 61 | accessControllerNodeId: 'ac1', 62 | }, 63 | ], 64 | function () { 65 | AC1 = helper.getNode("ac1") 66 | R1 = helper.getNode("r1") 67 | P1 = helper.getNode("p1") 68 | 69 | AC1.should.have.property('name', 'UDM Pro'); 70 | R1.should.have.property('name', 'UDM Pro Requester'); 71 | P1.should.have.property('name', 'Protect'); 72 | 73 | 74 | done() 75 | } 76 | ) 77 | .catch((error: any) => { 78 | done(new Error(error)) 79 | }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/types/AccessControllerNodeConfigType.ts: -------------------------------------------------------------------------------- 1 | import { NodeDef } from 'node-red' 2 | 3 | import ControllerType from './ControllerType' 4 | 5 | type AccessControllerNodeConfigType = NodeDef & { 6 | name: string 7 | controllerIp: string 8 | controllerPort?: string 9 | wsPort?: string 10 | controllerType?: ControllerType 11 | protectSocketReconnectTimeout?: string 12 | protectSocketHeartbeatInterval?: string 13 | } 14 | 15 | export default AccessControllerNodeConfigType 16 | -------------------------------------------------------------------------------- /src/types/AccessControllerNodeType.ts: -------------------------------------------------------------------------------- 1 | import { Method, ResponseType } from 'axios' 2 | import { Node } from 'node-red' 3 | 4 | import { SharedProtectWebSocket } from '../SharedProtectWebSocket' 5 | import AccessControllerNodeConfigType from './AccessControllerNodeConfigType' 6 | import { Bootstrap } from './Bootstrap' 7 | import ControllerType from './ControllerType' 8 | import { UnifiResponse } from './UnifiResponse' 9 | 10 | type AccessControllerNodeType = Node & { 11 | config: AccessControllerNodeConfigType 12 | getAuthCookie: (regenerate?: boolean) => Promise 13 | authCookie: string | undefined // Authorization TOKEN cookie 14 | abortController: AbortController // controller used to cancel auth request 15 | request: ( 16 | nodeId: string, 17 | endpoint?: string, 18 | method?: Method, 19 | data?: any, 20 | responseType?: ResponseType 21 | ) => Promise 22 | initialized: boolean // If node started successfully together with test auth 23 | stopped: boolean // If node stopped due to delete or restart 24 | authenticated: boolean // If node is authenticated (it will be also true if timeout) 25 | credentials: { 26 | // For authentication, you can use Local Admin with Read Only 27 | username: string 28 | password: string 29 | } 30 | // Either UniFi OS Console for UDM or UniFi Network Application for custom app env 31 | controllerType: ControllerType 32 | 33 | // The current bootstrap (more importantly the lastUpdateId and Cam ID's) 34 | bootstrapObject?: Bootstrap 35 | 36 | // The Shared Websocket used by all Protect Nodes 37 | protectSharedWS?: SharedProtectWebSocket 38 | } 39 | 40 | export default AccessControllerNodeType 41 | -------------------------------------------------------------------------------- /src/types/Bootstrap.ts: -------------------------------------------------------------------------------- 1 | export type Bootstrap = { 2 | cameras?: Camera[] 3 | lastUpdateId?: string 4 | } 5 | 6 | export type Camera = { 7 | name: string 8 | type: string 9 | id: string 10 | } 11 | -------------------------------------------------------------------------------- /src/types/ControllerType.ts: -------------------------------------------------------------------------------- 1 | type ControllerType = 'UniFiOSConsole' | 'UniFiNetworkApplication' 2 | 3 | export default ControllerType 4 | -------------------------------------------------------------------------------- /src/types/HttpError.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | constructor(message: string, public status: number) { 3 | super(message) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/types/ProtectNodeConfigType.ts: -------------------------------------------------------------------------------- 1 | import { NodeDef } from 'node-red' 2 | 3 | type ProtectNodeConfigType = NodeDef & { 4 | accessControllerNodeId: string 5 | cameraIds: string 6 | eventIds: string 7 | debug: boolean 8 | } 9 | export default ProtectNodeConfigType 10 | -------------------------------------------------------------------------------- /src/types/ProtectNodeType.ts: -------------------------------------------------------------------------------- 1 | import { NodeMessage } from '@node-red/registry' 2 | import { Node } from 'node-red' 3 | 4 | import AccessControllerNodeType from './AccessControllerNodeType' 5 | import ProtectNodeConfigType from './ProtectNodeConfigType' 6 | 7 | type ProtectNodeType = Node & { 8 | config: ProtectNodeConfigType 9 | accessControllerNode: AccessControllerNodeType 10 | } & { 11 | send(msg?: any | NodeMessage | NodeMessage[]): void 12 | } 13 | 14 | export default ProtectNodeType 15 | -------------------------------------------------------------------------------- /src/types/RequestNodeConfigType.ts: -------------------------------------------------------------------------------- 1 | import { Method, ResponseType } from 'axios' 2 | import { NodeDef } from 'node-red' 3 | 4 | type RequestNodeConfigType = NodeDef & { 5 | accessControllerNodeId: string 6 | endpoint?: string 7 | method: Method 8 | data?: any 9 | responseType?: ResponseType 10 | } 11 | 12 | export default RequestNodeConfigType 13 | -------------------------------------------------------------------------------- /src/types/RequestNodeInputPayloadType.ts: -------------------------------------------------------------------------------- 1 | import { Method, ResponseType } from 'axios' 2 | 3 | type RequestNodeInputPayloadType = { 4 | endpoint?: string 5 | method?: Method 6 | data?: any 7 | responseType?: ResponseType 8 | } 9 | 10 | export default RequestNodeInputPayloadType 11 | -------------------------------------------------------------------------------- /src/types/RequestNodeType.ts: -------------------------------------------------------------------------------- 1 | import { NodeMessage } from '@node-red/registry' 2 | import { Node } from 'node-red' 3 | 4 | import AccessControllerNodeType from './AccessControllerNodeType' 5 | import RequestNodeConfigType from './RequestNodeConfigType' 6 | 7 | type RequestNodeType = Node & { 8 | config: RequestNodeConfigType 9 | accessControllerNode: AccessControllerNodeType 10 | } & { 11 | send(msg?: any | NodeMessage | NodeMessage[]): void 12 | } 13 | 14 | export default RequestNodeType 15 | -------------------------------------------------------------------------------- /src/types/UnifiResponse.ts: -------------------------------------------------------------------------------- 1 | export enum UnifiResponseMetaMsg { 2 | NO_SITE_CONTEXT = 'api.err.NoSiteContext', 3 | INVALID_PAYLOAD = 'api.err.InvalidPayload', 4 | } 5 | 6 | export enum UnifiResponseMetaRc { 7 | ERROR = 'error', 8 | OK = 'ok', 9 | } 10 | 11 | export type ValidationError = { 12 | field?: string 13 | pattern?: string 14 | msg?: UnifiResponseMetaMsg 15 | } 16 | 17 | export type Meta = { 18 | rc: UnifiResponseMetaRc 19 | validationError?: ValidationError 20 | msg?: string 21 | } 22 | 23 | export type UnifiResponse = { 24 | meta: Meta 25 | data: any 26 | } 27 | -------------------------------------------------------------------------------- /src/types/WebSocketNodeConfigType.ts: -------------------------------------------------------------------------------- 1 | import { NodeDef } from 'node-red' 2 | 3 | type WebSocketNodeConfigType = NodeDef & { 4 | /** 5 | * AccessController config node ID set up by Node-RED UI selector 6 | */ 7 | accessControllerNodeId: string 8 | /** 9 | * UniFi web socket endpoint. For example /proxy/network/wss/s/default/events or /api/ws/system 10 | */ 11 | endpoint?: string 12 | /** 13 | * How long in milliseconds to wait until trying to reconnect web socket client 14 | */ 15 | reconnectTimeout?: number 16 | } 17 | 18 | export default WebSocketNodeConfigType 19 | -------------------------------------------------------------------------------- /src/types/WebSocketNodeInputPayloadType.ts: -------------------------------------------------------------------------------- 1 | type WebSocketNodeInputPayloadType = { 2 | endpoint?: string 3 | } 4 | 5 | export default WebSocketNodeInputPayloadType 6 | -------------------------------------------------------------------------------- /src/types/WebSocketNodeType.ts: -------------------------------------------------------------------------------- 1 | import { Node } from 'node-red' 2 | import WebSocket from 'ws' 3 | 4 | import AccessControllerNodeType from './AccessControllerNodeType' 5 | import WebSocketNodeConfigType from './WebSocketNodeConfigType' 6 | 7 | type WebSocketNodeType = Node & { 8 | config: WebSocketNodeConfigType 9 | accessControllerNode: AccessControllerNodeType 10 | endpoint?: string 11 | ws?: WebSocket & { id?: string } 12 | } 13 | 14 | export default WebSocketNodeType 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020.string" 7 | ], 8 | "skipLibCheck": true, 9 | "allowJs": true, 10 | "checkJs": false, 11 | "outDir": "./build", 12 | "removeComments": true, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "strictBindCallApply": true, 18 | "strictPropertyInitialization": true, 19 | "noImplicitThis": true, 20 | "alwaysStrict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "moduleResolution": "node", 26 | "esModuleInterop": true, 27 | "forceConsistentCasingInFileNames": true, 28 | "resolveJsonModule": true 29 | }, 30 | "include": [ 31 | "src" 32 | ], 33 | "exclude": [ 34 | "node_modules", 35 | "**/test/*", 36 | "build" 37 | ] 38 | } 39 | --------------------------------------------------------------------------------