├── .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 |
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 |
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 |
--------------------------------------------------------------------------------