├── LICENSE
├── README.md
├── packageManifest.json
├── server.py
├── unifiProtectCamera
├── unifiProtectChime
├── unifiProtectController
├── unifiProtectDoorbell
├── unifiProtectLight
└── unifiProtectViewer
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # hubitat_unifiProtect
2 |
3 | This provides various driver capabilities for Hubitat with UniFi Protect systems. Supported devices include:
4 | * cameras (Motion and ImageCapture capabilities, as well as Smart Detect for supported cameras)
5 | * chimes (AudioVolume and Switch capabilities, for adjusting Protect Chime volume)
6 | * doorbells (Motion, Notification (LCD screen message), and PushableButton (for doorbell button presses))
7 | * lights (Motion, Switch, and SwitchLevel (for dimming the light))
8 | * viewers (for selecting ViewPort views based on saved liveviews)
9 | * *more capabilities may be added over time.*
10 |
11 |
12 | Most of this implementation is based on the work shared here:
13 |
14 | * https://github.com/hjdhjd/homebridge-unifi-protect
15 |
16 | Special thanks to @Bago for their troubleshooting help.
17 |
18 | # Manual Installation instructions:
19 |
20 | * In the *Drivers Code* section of Hubitat, add the unifiProtectController, unifiProtectCamera, and unifiProtectDoorbell drivers.
21 | * Optionally add the remaining drivers if your system contains those additional device types.
22 | * In the *Devices* section of Hubitat, add a *New Virtual Device* of type UniFi Protect Controller.
23 | * On the configuration page for the newly created *Device*, enter these details and Save:
24 | * username and password for your UniFi Protect controller
25 | * the IP address of your UniFi Protect controller
26 |
27 |
28 | # Usage instructions:
29 |
30 | * Use ```createChildDevices()``` to create specific instances for all known devices (from the 'bootstrap' *Device States* entry)
31 | * Utilize Motion (from cameras, doorbells, and lights) and PushableButton (from doorbell) events, according to whatever is supported by your devices
32 | * Use ```on()```, ```off()```, and ```setLevel()``` on lights
33 | * Use the ```take()``` command on camera devices to take a snapshot from the main lens of the camera
34 | * This will store an image locally in the File Manager of your Hubitat hub (requires Hubitat software 2.3.4.132 or later).
35 | * To display or use the stored image, use my companion app *hubitat_imageServer* (available through HPM and GitHub).
36 | * Use the ```takePicture()``` command for capturing snapshots from camera devices that have multiple lenses, like the G4 Doorbell Pro.
37 | * Use the ```setRecordingMode(mode)``` on camera devices to modify the recording mode. Supported modes: "always", "never", and either "motion" or "detections" (try both to see which is correct) depending on your version of UniFi Protect.
38 | * Use the ```deviceNotification()``` command on doorbell devices to print a message to the LCD screen
39 | * Messages are limited to 30 characters by the UniFi Protect system
40 |
41 | # Disclaimer
42 |
43 | I have no affiliation with any of the companies mentioned in this readme or in the code.
44 |
--------------------------------------------------------------------------------
/packageManifest.json:
--------------------------------------------------------------------------------
1 |
2 | {
3 | "packageName": "hubitat_unifiProtect",
4 | "author": "tomw",
5 | "version": "1.4.8",
6 | "minimumHEVersion": "2.3.4.132",
7 | "dateReleased": "2021-01-08",
8 | "drivers": [
9 | {
10 | "id": "2a27425d-51fe-4c9b-89b3-d9dada9b09df",
11 | "name": "UniFi Protect Controller",
12 | "namespace": "tomw",
13 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/unifiProtectController",
14 | "required": true
15 | },
16 | {
17 | "id": "13544119-363d-479b-8fdf-4a61bc682fc3",
18 | "name": "UniFi Protect Camera",
19 | "namespace": "tomw",
20 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/unifiProtectCamera",
21 | "required": true
22 | },
23 | {
24 | "id": "1cdebfe0-5b8c-4acb-99ee-ae4aa1ef4e15",
25 | "name": "UniFi Protect Chime",
26 | "namespace": "tomw",
27 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/unifiProtectChime",
28 | "required": false
29 | },
30 | {
31 | "id": "d3a0bc6c-67c1-46fb-8dec-dca6915d701e",
32 | "name": "UniFi Protect Doorbell",
33 | "namespace": "tomw",
34 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/unifiProtectDoorbell",
35 | "required": true
36 | },
37 | {
38 | "id": "edfa53bc-fe44-431a-aa75-1eda1620b80c",
39 | "name": "UniFi Protect Light",
40 | "namespace": "tomw",
41 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/unifiProtectLight",
42 | "required": false
43 | },
44 | {
45 | "id": "b21183ec-ce25-42a2-a749-a096f17cfdb8",
46 | "name": "UniFi Protect Viewer",
47 | "namespace": "tomw",
48 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/unifiProtectViewer",
49 | "required": false
50 | }
51 | ],
52 | "licenseFile": "https://raw.githubusercontent.com/tomwpublic/hubitat_unifiProtect/main/LICENSE",
53 | "releaseNotes": "1.4.8 - tomw - Bugfix for duplicate smart detect events\n1.4.7 - tomw - Bugfixes for missing smart detect events\n1.4.6 - tomw - Bugfix for UniFi OS 3.2.x\n1.4.5 - tomw - Bugfixes for motion on Lights\n1.4.4 - tomw - Support chimes and viewports\n1.4.3 - tomw - Added adjustChimeType command. Can be used to disable and enable chimes in automations.\n1.4.2 - tomw - Image file handling improvements, including direct viewing in browser from File Manager\n1.4.1 - tomw - Performance improvements to reduce hub load\n1.4.0 - tomw - Store images from 'take' in File Manager\n1.3.9 - tomw - Save snapWidth and snapHeight to device data, for use by imageServerApp\n1.3.8 - tomw - Add camera LED indicator control (capability Switch)\n1.3.7 - tomw - Support G4 Doorbell Pro\n1.3.6 - tomw - Improved event handling\n1.3.5 - tomw - Bugfix for non-deflated event packets\n1.3.4 - tomw - Added light support and \"isDark\" attribute on cameras and lights\n1.3.3 - tomw - Bugfix in login flow\n1.3.2 - tomw - Added command for setting recording mode. Supported values are always/motion/never.\n1.3.1 - tomw - Hide unused server.py preferences for IP/port\n1.3.0 - tomw - Local processing for events. No server.py required with Hubitat 2.2.8.143 and later.\n1.2.4 - tomw - Improved recovery behavior when Initialize fails. Reduced State storage usage.\n1.2.3 - tomw - Bugfixes for websocket error handling.\n1.2.2 - tomw - Improvements for websocket uptime.\n1.2.1 - tomw - Added configurable snapshot size. Default size is 640 x 360.\n1.2.0 - tomw - Added doorbell LCD message support.\n1.1.0 - tomw - Added Smart Detect support (for supported cameras only, must be configured in UniFi Protect controller).\n1.0.0 - tomw - Initial release.",
54 | "documentationLink": "https://github.com/tomwpublic/hubitat_unifiProtect/blob/main/README.md",
55 | "communityLink": "https://community.hubitat.com/t/ubiquiti-unifi-protect-cameras/17624/37"
56 | }
57 |
--------------------------------------------------------------------------------
/server.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | """
3 | Very simple HTTP server in python for logging requests
4 | Usage::
5 | ./server.py []
6 |
7 | based on this example: https://gist.github.com/mdonkers/63e115cc0c79b4f6b8b3a6b797e485c7
8 |
9 | """
10 | from http.server import BaseHTTPRequestHandler, HTTPServer
11 | import logging
12 | import zlib
13 |
14 | class S(BaseHTTPRequestHandler):
15 | def _set_response(self):
16 | self.send_response(200)
17 | self.send_header('Content-type', 'text/html')
18 | self.end_headers()
19 |
20 | def do_POST(self):
21 | content_length = int(self.headers['Content-Length']) # <--- Gets the size of data
22 | post_data = self.rfile.read(content_length) # <--- Gets the data itself
23 | #logging.info("POST request,\nPath: %s\nHeaders:\n%s\n\nBody:\n%s\n",
24 | # str(self.path), str(self.headers), post_data.decode('utf-8'))
25 |
26 | bytes_hex = bytes.fromhex(post_data.decode("utf-8"))
27 | decompressed = zlib.decompress(bytes_hex)
28 | logging.info("decompressed = %s", decompressed)
29 |
30 | self._set_response()
31 | self.wfile.write(decompressed)
32 |
33 | def run(server_class=HTTPServer, handler_class=S, port=2112):
34 | logging.basicConfig(level=logging.INFO)
35 | server_address = ('', port)
36 | httpd = server_class(server_address, handler_class)
37 | logging.info('Starting httpd...\n')
38 | try:
39 | httpd.serve_forever()
40 | except KeyboardInterrupt:
41 | pass
42 | httpd.server_close()
43 | logging.info('Stopping httpd...\n')
44 |
45 | if __name__ == '__main__':
46 | from sys import argv
47 |
48 | if len(argv) == 2:
49 | run(port=int(argv[1]))
50 | else:
51 | run()
52 |
--------------------------------------------------------------------------------
/unifiProtectCamera:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Copyright 2020 - tomw
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
17 | -------------------------------------------
18 |
19 | Change history:
20 |
21 | 1.4.8 - tomw - Bugfix for duplicate smart detect events
22 | 1.4.7 - tomw - Bugfixes for missing smart detect events
23 | 1.4.4 - tomw - Bugfixes for camera event handling
24 | 1.4.2 - tomw - Image file handling improvements, including direct viewing in browser from File Manager
25 | 1.4.0 - tomw - Store images from 'take' in File Manager
26 | 1.3.9 - tomw - Save snapWidth and snapHeight to device data, for use by imageServerApp
27 | 1.3.8 - tomw - Add camera LED indicator control (capability Switch)
28 | 1.3.7 - tomw - Support G4 Doorbell Pro
29 | 1.3.6 - tomw - Improved event handling
30 | 1.3.4 - tomw - Added light support and "isDark" attribute on cameras and lights
31 | 1.3.2 - tomw - Added command for setting recording mode. Supported values are always/motion/never.
32 | 1.2.1 - tomw - Added configurable snapshot size. Default size is 640 x 360.
33 | 1.1.0 - tomw - Added Smart Detect support (for supported cameras only, must be configured in UniFi Protect controller)
34 | 1.0.0 - tomw - Initial release
35 |
36 | */
37 |
38 | metadata
39 | {
40 | definition(name: "UniFi Protect Camera", namespace: "tomw", author: "tomw", importUrl: "")
41 | {
42 | capability "ImageCapture"
43 | capability "MotionSensor"
44 | capability "Switch"
45 |
46 | command "on", [[name: "turn on status LED", type:"BOOL", description:"turn on status LED"]]
47 | command "off", [[name: "turn off status LED", type:"BOOL", description:"turn off status LED"]]
48 |
49 | command "clearImages"
50 | attribute "imageTimestamp", "string"
51 |
52 | command "setRecordingMode", ["mode"]
53 |
54 | command "takePicture", [[name: "Camera lens to use", type:"ENUM", constraints: knownLensUrls().keySet()]]
55 |
56 | attribute "isDark", "enum", ["true", "false"]
57 | attribute "smartDetectType", "string"
58 | }
59 | }
60 |
61 | preferences
62 | {
63 | section
64 | {
65 | input name: "snapWidth", type: "number", title: "snapshot width", defaultValue: 640
66 | input name: "snapHeight", type: "number", title: "snapshot height", defaultValue: 360
67 | }
68 | }
69 |
70 | def installed()
71 | {
72 | device.updateSetting("snapWidth", 640)
73 | device.updateSetting("snapHeight", 360)
74 |
75 | updated()
76 | }
77 |
78 | def updated()
79 | {
80 | updateDataValue("snapWidth", snapWidth.toString())
81 | updateDataValue("snapHeight", snapHeight.toString())
82 | }
83 |
84 | def uninstalled()
85 | {
86 | deleteImageFile()
87 | }
88 |
89 | def processEvent(event)
90 | {
91 | if(!event)
92 | {
93 | return
94 | }
95 |
96 | if(null != event.isDark)
97 | {
98 | sendEvent(name: "isDark", value: event.isDark)
99 | }
100 |
101 | if(null != event.isMotionDetected)
102 | {
103 | sendEvent(name: "motion", value: event.isMotionDetected ? "active" : "inactive")
104 | }
105 |
106 | if(null != event.ledSettings?.isEnabled)
107 | {
108 | sendEvent(name: "switch", value: event.ledSettings.isEnabled ? "on" : "off")
109 | }
110 |
111 | if(!hasSmartDetect()) { return }
112 |
113 | if(false == event.isSmartDetected)
114 | {
115 | smartDetectEvent(null)
116 | return
117 | }
118 |
119 | if(null != event.smartDetectTypes)
120 | {
121 | if(null != event.metadata?.detectedAreas)
122 | {
123 | // ignore extra events returned by newer versions of Protect
124 | return
125 | }
126 |
127 | smartDetectEvent(event.smartDetectTypes)
128 | }
129 | }
130 |
131 | def smartDetectEvent(smartDetectTypes)
132 | {
133 | if(null == smartDetectTypes)
134 | {
135 | // clear event state to default value
136 | safeSendEvent([name: "smartDetectType", value: "waiting"])
137 | return
138 | }
139 |
140 | smartDetectTypes.each
141 | {
142 | safeSendEvent([name: "smartDetectType", value: it])
143 | }
144 | }
145 |
146 | def knownLensUrls()
147 | {
148 | def urls =
149 | [
150 | "main": "snapshot",
151 | "package": "package-snapshot"
152 | ]
153 |
154 | return urls
155 | }
156 |
157 | def checkCamInfo(ffPath)
158 | {
159 | if(null != state.getAt(ffPath))
160 | {
161 | return state.getAt(ffPath)
162 | }
163 |
164 | def cam = getParent()?.getBootstrap()?.cameras.find() { it.id == getCameraId() }
165 | if(cam)
166 | {
167 | def val = cam.featureFlags?.getAt(ffPath)
168 | state.putAt(ffPath, val)
169 | return val
170 | }
171 | }
172 |
173 | def hasSmartDetect()
174 | {
175 | checkCamInfo("hasSmartDetect")
176 | }
177 |
178 | def hasPackageCamera()
179 | {
180 | checkCamInfo("hasPackageCamera")
181 | }
182 |
183 | def takePicture(lens = "main")
184 | {
185 | try
186 | {
187 | if(!knownLensUrls().keySet().contains(lens))
188 | {
189 | throw new Exception("\"${lens}\" lens type not supported")
190 | }
191 |
192 | if("package" == lens && !hasPackageCamera())
193 | {
194 | throw new Exception("\"${lens}\" lens type not supported")
195 | }
196 |
197 | take(lens)
198 | }
199 | catch (Exception e)
200 | {
201 | log.warn e.message
202 | take()
203 | }
204 | }
205 |
206 | def take(lens = "main")
207 | {
208 | try
209 | {
210 | def stream = getParent()?.httpExecWithAuthCheck("GET", parent?.genParamsMain(getCameraSnapSuffix(lens)), true)?.data
211 |
212 | if(stream)
213 | {
214 | def bSize = stream.available()
215 | byte[] imageArr = new byte[bSize]
216 | stream.read(imageArr, 0, bSize)
217 |
218 | deleteImageFile(currentImageFile())
219 | writeImageToFile(imageArr)
220 |
221 | sendEvent(name: "image", value: "file:${fileName()}", isStateChange: true)
222 | sendEvent(name: "imageTimestamp", value: now())
223 | }
224 | }
225 | catch(groovy.lang.MissingMethodException e)
226 | {
227 | def errMsg = "take() failed: "
228 | if(e.message.contains("uploadHubFile"))
229 | {
230 | errMsg += "You must update your Hubitat software to at least version 2.3.4.132."
231 | }
232 | else
233 | {
234 | errMsg += e.message
235 | }
236 |
237 | log.error errMsg
238 | return
239 | }
240 | catch (Exception e)
241 | {
242 | log.debug "take() failed: ${e.message}"
243 | }
244 | }
245 |
246 | def clearImages()
247 | {
248 | deleteImageFile()
249 | sendEvent(name: "image", value: "n/a")
250 | sendEvent(name: "imageTimestamp", value: "n/a")
251 | }
252 |
253 | def setRecordingMode(mode)
254 | {
255 | if(!(["always", "motion", "never", "detections"].contains(mode)))
256 | {
257 | log.debug "unsupported recording mode (${mode})"
258 | return
259 | }
260 |
261 | updateCamera([recordingSettings: [mode: mode]])
262 | }
263 |
264 | def on()
265 | {
266 | updateCamera([ledSettings: [isEnabled: true]])
267 | }
268 |
269 | def off()
270 | {
271 | updateCamera([ledSettings: [isEnabled: false]])
272 | }
273 |
274 | def updateCamera(Map data)
275 | {
276 | try
277 | {
278 | getParent()?.httpExecWithAuthCheck("PATCH", parent?.genParamsMain(getCameraBaseUrl(), new groovy.json.JsonOutput().toJson(data)), true)
279 | }
280 | catch (Exception e)
281 | {
282 | log.debug "updateCamera(${data}) failed: ${e.message}"
283 | }
284 | }
285 |
286 | def getCameraId()
287 | {
288 | return device.getDeviceNetworkId()?.split('-')?.getAt(0)
289 | }
290 |
291 | def getCameraBaseUrl()
292 | {
293 | return "/proxy/protect/api/cameras/" + getCameraId()
294 | }
295 |
296 | def getCameraSnapSuffix(lens = "main")
297 | {
298 | return getCameraBaseUrl() + "/${knownLensUrls()?.getAt(lens)}?force=true&w=${snapWidth ?: 640}&h=${snapHeight ?: 360}"
299 | }
300 |
301 | void safeSendEvent(Map event)
302 | {
303 | if(!event.keySet().containsAll(["name", "value"])) { return }
304 |
305 | if(device.currentValue(event.name) == event.value) { return }
306 | sendEvent(event)
307 | }
308 |
309 | //////////////////////////////////////
310 | // File system operations
311 | //////////////////////////////////////
312 |
313 | def fileName()
314 | {
315 | return [device.getDisplayName()?.replace(" ", "_") ?: "", device.getDeviceNetworkId()].join("_") + ".jpg"
316 | }
317 |
318 | def writeImageToFile(byte[] image)
319 | {
320 | if(null == image) { return }
321 |
322 | uploadHubFile(fileName(), image)
323 | }
324 |
325 | def deleteImageFile(fileName = fileName())
326 | {
327 | if(invalidImageVals().contains(fileName)) { return }
328 |
329 | deleteHubFile(fileName)
330 | }
331 |
332 | def currentImageFile()
333 | {
334 | return device.currentValue("image")?.replace("file:", "")
335 | }
336 |
337 | def invalidImageVals()
338 | {
339 | // "n/a" is the default state after clearImages()
340 | return [null, "n/a"]
341 | }
342 |
--------------------------------------------------------------------------------
/unifiProtectChime:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Copyright 2023 - tomw
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
17 | -------------------------------------------
18 |
19 | Change history:
20 |
21 | 1.4.4 - tomw - Support chimes and viewports
22 |
23 | */
24 |
25 | metadata
26 | {
27 | definition(name: "UniFi Protect Chime", namespace: "tomw", author: "tomw", importUrl: "")
28 | {
29 | capability "AudioVolume"
30 | capability "Switch"
31 |
32 | command "testChime"
33 |
34 | attribute "previousVolume", "number"
35 | }
36 | }
37 |
38 | def processEvent(event)
39 | {
40 | if(!event)
41 | {
42 | return
43 | }
44 |
45 | def events = [[:]]
46 | if(null != event.volume)
47 | {
48 | def isUnmuted = event.volume > 0
49 |
50 | events += [name: "mute", value: isUnmuted ? "unmuted" : "muted"]
51 | events += [name: "volume", value: event.volume]
52 |
53 | events += [name: "switch", value: isUnmuted ? "on" : "off"]
54 | }
55 |
56 | events.each
57 | {
58 | sendEvent(it)
59 | }
60 | }
61 |
62 | def volumeDown()
63 | {
64 | setVolume(currentVolume() - 5)
65 | }
66 |
67 | def volumeUp()
68 | {
69 | setVolume(currentVolume() + 5)
70 | }
71 |
72 | def mute()
73 | {
74 | setVolume(0, true)
75 | }
76 |
77 | def unmute()
78 | {
79 | setVolume(previousVolume())
80 | }
81 |
82 | def on()
83 | {
84 | unmute()
85 | }
86 |
87 | def off()
88 | {
89 | mute()
90 | }
91 |
92 | def setVolume(volume, isMute = false)
93 | {
94 | if(null == volume) { return }
95 |
96 | // bound to [0..100]
97 | volume = (volume > 100) ? 100 : ((volume < 0) ? 0 : volume)
98 |
99 | updateChime([volume: volume])
100 |
101 | if(!isMute)
102 | {
103 | // we need this for things like unmute()
104 | sendEvent(name: "previousVolume", value: volume)
105 | }
106 | }
107 |
108 | def currentVolume()
109 | {
110 | // return 0 if this is 0 or null
111 | return device.currentValue("volume") ?: 0
112 | }
113 |
114 | def previousVolume()
115 | {
116 | // return 0 if this is 0 or null
117 | return device.currentValue("previousVolume") ?: 0
118 | }
119 |
120 | def updateChime(dataMap)
121 | {
122 | try
123 | {
124 | getParent()?.httpExecWithAuthCheck("PATCH", parent?.genParamsMain(getChimesUrlSuffix(), new groovy.json.JsonOutput().toJson(dataMap)), true)
125 | }
126 | catch (Exception e)
127 | {
128 | log.debug "updateChime(${dataMap}) failed: ${e.message}"
129 | }
130 | }
131 |
132 | def testChime()
133 | {
134 | try
135 | {
136 | getParent()?.httpExecWithAuthCheck("POST", parent?.genParamsMain(getChimesUrlSuffix() + "/play-speaker"), true)
137 | }
138 | catch (Exception e)
139 | {
140 | log.debug "testChime() failed: ${e.message}"
141 | }
142 | }
143 |
144 | def getChimesUrlSuffix()
145 | {
146 | def id = device.getDeviceNetworkId()?.split('-')?.getAt(0)
147 | def baseUrlSuffix = "/proxy/protect/api/chimes/" + id
148 |
149 | return baseUrlSuffix
150 | }
151 |
--------------------------------------------------------------------------------
/unifiProtectController:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Copyright 2020 - tomw
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
17 | -------------------------------------------
18 |
19 | Change history:
20 |
21 | 1.4.7 - tomw - Bugfixes for missing smart detect events
22 | 1.4.6 - tomw - Bugfix for UniFi OS 3.2.x
23 | 1.4.5 - tomw - Bugfixes for motion on Lights
24 | 1.4.4 - tomw - Support chimes and viewports
25 | 1.4.1 - tomw - Performance improvements to reduce hub load
26 | 1.3.7 - tomw - Support G4 Doorbell Pro
27 | 1.3.6 - tomw - Improved event handling
28 | 1.3.5 - tomw - Bugfix for non-deflated event packets (exposed by controller version 2.1.1-beta.3)
29 | 1.3.4 - tomw - Added light support and "isDark" attribute on cameras and lights
30 | 1.3.3 - tomw - Bugfix in login flow
31 | 1.3.1 - tomw - Hide unused server.py preferences for IP/port
32 | 1.3.0 - tomw - Local processing for events. No server.py required with Hubitat 2.2.8.143 and later
33 | 1.2.4 - tomw - Improved recovery behavior when Initialize fails. Reduced State storage usage.
34 | 1.2.3 - tomw - Bugfixes for websocket error handling
35 | 1.2.2 - tomw - Improvements for websocket uptime
36 | 1.2.0 - tomw - Added doorbell LCD message support
37 | 1.1.0 - tomw - Added Smart Detect support (for supported cameras only, must be configured in UniFi Protect controller)
38 | 1.0.0 - tomw - Initial release
39 |
40 | */
41 |
42 | metadata
43 | {
44 | definition(name: "UniFi Protect Controller", namespace: "tomw", author: "tomw", importUrl: "")
45 | {
46 | capability "Initialize"
47 | capability "Refresh"
48 |
49 | command "createChildDevices"
50 | command "deleteChildDevices"
51 |
52 | attribute "commStatus", "string"
53 | }
54 | }
55 |
56 | preferences
57 | {
58 | section
59 | {
60 | input "controllerIP", "text", title: "UniFi controller IP", required: true
61 | }
62 | section
63 | {
64 | input "username", "text", title: "Username", required: true
65 | input "password", "password", title: "Password", required: true
66 | }
67 | section
68 | {
69 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
70 | input name: "disablePreFiltering", type: "bool", title: "Disable event pre-filtering
(not recommended, will consume more hub CPU)", defaultValue: false
71 | }
72 | }
73 |
74 | def logDebug(msg)
75 | {
76 | if (logEnable)
77 | {
78 | log.debug(msg)
79 | }
80 | }
81 |
82 | def updated()
83 | {
84 | initialize()
85 | }
86 |
87 | def initialize()
88 | {
89 | sendEvent(name: "commStatus", value: "unknown")
90 | try
91 | {
92 | unschedule()
93 | closeEventSocket()
94 |
95 | refreshCookie()
96 | refresh()
97 |
98 | runIn(5, openEventSocket)
99 |
100 | sendEvent(name: "commStatus", value: "good")
101 | }
102 | catch (Exception e)
103 | {
104 | logDebug("initialize() failed: ${e.message}")
105 | sendEvent(name: "commStatus", value: "error")
106 |
107 | reinitialize()
108 | }
109 | }
110 |
111 | def refresh()
112 | {
113 | def bs = readBootstrap()
114 | if(!bs) { return }
115 |
116 |
117 | // update child devices with current states
118 | def devsToRefresh =
119 | [ bs.cameras, bs.chimes, bs.lights, bs.viewers ]
120 |
121 | devsToRefresh.each
122 | {
123 | it?.each { processEvents(it.id, it) }
124 | }
125 | }
126 |
127 | def genParamsAuth()
128 | {
129 | def params =
130 | [
131 | uri: getBaseURI() + getLoginSuffix(),
132 | headers:
133 | [
134 | (csrfTokenNameToSend()): getCsrf()
135 | ],
136 |
137 | contentType: "application/json",
138 | requestContentType: "application/json",
139 | body: "{\"username\": \"${username}\", \"password\": \"${password}\"}",
140 | ignoreSSLIssues: true
141 | ]
142 |
143 | return params
144 | }
145 |
146 | def genParamsMain(suffix, body = null)
147 | {
148 | def params =
149 | [
150 | uri: getBaseURI() + suffix,
151 | headers:
152 | [
153 | 'Cookie': getCookie(),
154 | (csrfTokenNameToSend()): getCsrf()
155 | ],
156 | ignoreSSLIssues: true,
157 | ]
158 |
159 | if(body)
160 | {
161 | params['body'] = body
162 | params['contentType'] = 'application/json'
163 | params['requestContentType'] = 'application/json'
164 | }
165 |
166 | return params
167 | }
168 |
169 | def genHeadersWss()
170 | {
171 | def headers =
172 | [
173 | 'Cookie': getCookie()
174 | ]
175 |
176 | return headers
177 | }
178 |
179 | def getBaseURI()
180 | {
181 | return "https://${controllerIP}"
182 | }
183 |
184 | def getLoginSuffix()
185 | {
186 | return "/api/auth/login"
187 | }
188 |
189 | def getBootstrapSuffix()
190 | {
191 | return "/proxy/protect/api/bootstrap"
192 | }
193 |
194 | def getWssURI(id)
195 | {
196 | return "wss://${controllerIP}" + "/proxy/protect/ws/updates?" + "lastUpdateId=${id}";
197 | }
198 |
199 | def login()
200 | {
201 | def resp = httpExec("POST", genParamsAuth())
202 | //logDebug("login = ${resp.data}")
203 |
204 | updateAuthTokensStore(resp)
205 | }
206 |
207 | def csrfTokenNameToMatch()
208 | {
209 | return "X-CSRF-Token".toUpperCase()
210 | }
211 |
212 | def csrfTokenNameToSend()
213 | {
214 | return "X-CSRF-Token"
215 | }
216 |
217 | def updateAuthTokensStore(resp)
218 | {
219 | if(resp)
220 | {
221 | resp?.getHeaders()?.each
222 | {
223 | //logDebug("header: ${it.getName()} == ${it.getValue()}")
224 |
225 | if(it.getName()?.toString()?.toUpperCase() == csrfTokenNameToMatch())
226 | {
227 | setCsrf(it.getValue())
228 | }
229 |
230 | if(it.getName()?.toString() == "Set-Cookie")
231 | {
232 | setCookie(it.getValue()?.split(';')[0])
233 |
234 | // extract the expiration time from the cookie
235 | def rawToken = it.getValue()?.split('=')[1]
236 |
237 | //setExpir(new groovy.json.JsonSlurper().parseText(new String(rawToken?.tokenize(".")?.getAt(1)?.decodeBase64()))?.exp)
238 | // Use `iat` instead, because some newer UniFi OS JWT reponses don't seen to have `exp`.
239 | // Assumption is that the token is valid for 60 minutes.
240 | setExpir(new groovy.json.JsonSlurper().parseText(new String(rawToken?.tokenize(".")?.getAt(1)?.decodeBase64()))?.iat + (60 * 60))
241 |
242 | // schedule next refreshCookie 10 minutes before expiration
243 | def now = ((new Date().getTime()) / 1000).toInteger()
244 | runIn((getExpir() - now) - (60 * 10), reinitialize)
245 | }
246 | }
247 | }
248 | }
249 |
250 | def refreshCookie()
251 | {
252 | sendEvent(name: "commStatus", value: "unknown")
253 |
254 | try
255 | {
256 | login()
257 |
258 | sendEvent(name: "commStatus", value: "good")
259 | }
260 | catch (Exception e)
261 | {
262 | logDebug("refreshCookie() failed: ${e.message}")
263 | sendEvent(name: "commStatus", value: "error")
264 |
265 | throw(e)
266 | }
267 | }
268 |
269 | def manageChildDevice(it)
270 | {
271 | if(!it) { return }
272 |
273 | if((it.name && it.id) && !findChildDevice(it.id, it.typeName))
274 | {
275 | createChildDevice(it.name, it.id, it.typeName)
276 | }
277 |
278 | if((it.name && it.id) && !findChildDevice(it.id, "doorbell") &&
279 | (it.featureFlags?.isDoorbell || it.type?.toLowerCase()?.contains("doorbell")))
280 | {
281 | // special case to create additional device for doorbells
282 | createChildDevice(it.name, it.id, "doorbell")
283 | }
284 | }
285 |
286 | def createChildDevices()
287 | {
288 | def bs = getBootstrap()
289 |
290 | // check for and create child devices for supported types
291 | bs?.cameras?.each { manageChildDevice(it) }
292 | bs?.chimes?.each { manageChildDevice(it) }
293 | bs?.lights?.each { manageChildDevice(it) }
294 | bs?.viewers?.each { manageChildDevice(it) }
295 |
296 | // get initial states for all devices
297 | refresh()
298 | }
299 |
300 | def deleteChildDevices()
301 | {
302 | for(child in getChildDevices())
303 | {
304 | deleteChildDevice(child.deviceNetworkId)
305 | }
306 | }
307 |
308 | def webSocketStatus(String message)
309 | {
310 | logDebug("webSocketStatus: ${message}")
311 |
312 | // thanks for the idea: https://community.hubitat.com/t/websocket-client/11843/15
313 | if(message.startsWith("status: open"))
314 | {
315 | sendEvent(name: "commStatus", value: "good")
316 |
317 | state.reconnectDelay = 1
318 | setWasExpectedClose(false)
319 |
320 | return
321 | }
322 | else if(message.startsWith("status: closing"))
323 | {
324 | sendEvent(name: "commStatus", value: "no events")
325 | if(getWasExpectedClose())
326 | {
327 | setWasExpectedClose(false)
328 | return
329 | }
330 |
331 | reinitialize()
332 |
333 | return
334 | }
335 | else if(message.startsWith("failure:"))
336 | {
337 | sendEvent(name: "commStatus", value: "error")
338 | reinitialize()
339 |
340 | return
341 | }
342 | }
343 |
344 | def reinitialize()
345 | {
346 | // thanks @ogiewon for the example
347 |
348 | // first delay is 2 seconds, doubles every time
349 | def delayCalc = (state.reconnectDelay ?: 1) * 2
350 | // upper limit is 600s
351 | def reconnectDelay = delayCalc <= 600 ? delayCalc : 600
352 |
353 | state.reconnectDelay = reconnectDelay
354 | runIn(reconnectDelay, initialize)
355 | }
356 |
357 | def openEventSocket()
358 | {
359 | try
360 | {
361 | //logDebug("interfaces.webSocket.connect(${getWssURI(getBootstrap()?.lastUpdateId)}, headers: ${genHeadersWss()}, ignoreSSLIssues: true, perMessageDeflate: false)")
362 | interfaces.webSocket.connect(getWssURI(getBootstrap()?.lastUpdateId), headers: genHeadersWss(), ignoreSSLIssues: true, perMessageDeflate: false)
363 | }
364 | catch (Exception e)
365 | {
366 | logDebug("error: ${e.message}")
367 | sendEvent(name: "commStatus", value: "error")
368 | }
369 | }
370 |
371 | def closeEventSocket()
372 | {
373 | try
374 | {
375 | setWasExpectedClose(true)
376 | // wait for state to catch up
377 | pauseExecution(500)
378 |
379 | interfaces.webSocket.close()
380 | }
381 | catch (Exception e)
382 | {
383 | // swallow errors
384 | }
385 | }
386 |
387 | def parse(String message)
388 | {
389 | //logDebug("parse: ${message}")
390 | def packet = packetValidateAndDecode(message)
391 |
392 | if(packet)
393 | {
394 | if(
395 | ["camera", "chime", "light", "viewer"].contains(packet.actionPacket?.actionPayload?.modelKey?.toString()) &&
396 | packet.actionPacket?.actionPayload?.action?.toString() == "update"
397 | )
398 | {
399 | // process standard events on child devices
400 | processEvents(packet.actionPacket.actionPayload.id, packet.dataPacket?.dataPayload)
401 | }
402 |
403 | if(packet.actionPacket?.actionPayload?.modelKey?.toString() == "event")
404 | {
405 | def id =
406 | packet.actionPacket?.actionPayload?.recordId ?:
407 | packet.dataPacket?.dataPayload?.camera
408 |
409 | if(id)
410 | {
411 | // this is likely a Smart Detect event
412 | processEvents(id, packet.dataPacket?.dataPayload)
413 | }
414 | }
415 | }
416 | }
417 |
418 | def processEvents(id, event)
419 | {
420 | childTypeTranslation()?.keySet()?.each
421 | {
422 | findChildDevice(id, it)?.processEvent(event)
423 | }
424 | }
425 |
426 | def childName(name, deviceType)
427 | {
428 | return "${name.toString()}-${deviceType.toString()}"
429 | }
430 |
431 | def childDni(id, deviceType)
432 | {
433 | return "${id.toString()}-${deviceType.toString()}"
434 | }
435 |
436 | def findChildDevice(id, deviceType)
437 | {
438 | return getChildDevice(childDni(id, deviceType))
439 | }
440 |
441 | def childTypeTranslation()
442 | {
443 | // this translates the deviceType attribute I injected into bootstrap into the names of
444 | // the drivers (plus 'doorbell' as a special extension for cameras)
445 | return [ chime: "Chime", doorbell: "Doorbell", light: "Light",
446 | motion: "Camera", viewer: "Viewer" ]
447 | }
448 |
449 | def createChildDevice(name, id, deviceType)
450 | {
451 | // translate the metadata from getBootstrap() to what the driver names actually are
452 | def typeAlias = childTypeTranslation()?.getAt(deviceType)
453 |
454 | def child
455 | try
456 | {
457 | if(!typeAlias) { throw new Exception("Unknown device type: ${deviceType}") }
458 |
459 | addChildDevice("UniFi Protect " + typeAlias, childDni(id, deviceType), [name: childName(name, deviceType), label: childName(name, deviceType), isComponent: false])
460 | }
461 | catch(com.hubitat.app.exception.UnknownDeviceTypeException e)
462 | {
463 | log.info "Couldn't create device for ${name}. Check to make sure this driver is installed: UniFi Protect ${typeAlias}"
464 | }
465 | catch (Exception e)
466 | {
467 | logDebug("createChildDevice() failed: ${e.message}")
468 | }
469 | }
470 |
471 | def setCookie(cookie)
472 | {
473 | state.cookie = cookie
474 | }
475 |
476 | def getCookie()
477 | {
478 | return state.cookie
479 | }
480 |
481 | def setExpir(expir)
482 | {
483 | state.expir = expir
484 | }
485 |
486 | def getExpir()
487 | {
488 | return state.expir
489 | }
490 |
491 | def setCsrf(csrf)
492 | {
493 | state.csrf = csrf
494 | }
495 |
496 | def getCsrf()
497 | {
498 | return state.csrf
499 | }
500 |
501 | def setWasExpectedClose(wasExpected)
502 | {
503 | state.wasExpectedClose = wasExpected
504 | }
505 |
506 | def getWasExpectedClose()
507 | {
508 | return state.wasExpectedClose
509 | }
510 |
511 | def readBootstrap()
512 | {
513 | try
514 | {
515 | def resp = httpExecWithAuthCheck("GET", genParamsMain(getBootstrapSuffix()), true)
516 |
517 | def subBootstrap
518 | if(resp)
519 | {
520 | //logDebug("FULL BOOTSTRAP FOLLOWS:")
521 | //logDebug(resp.data)
522 |
523 | subBootstrap = scrubBootstrap(resp.data)
524 | setBootstrap(subBootstrap)
525 | }
526 |
527 | sendEvent(name: "commStatus", value: "good")
528 |
529 | return subBootstrap
530 | }
531 | catch (Exception e)
532 | {
533 | logDebug("readBootstrap() failed: ${e.message}")
534 | sendEvent(name: "commStatus", value: "error")
535 |
536 | throw(e)
537 | }
538 | }
539 |
540 | def scrubBootstrap(bootstrap)
541 | {
542 | if(!bootstrap) { return }
543 |
544 | // these are the keys we need for every device
545 | def baseWhitelist = ['id', 'marketName', 'modelKey', 'name', 'type']
546 |
547 | def camerasKeysWhitelist = baseWhitelist + ['chimeDuration', 'featureFlags', 'isDark', 'isMotionDetected',
548 | 'isSmartDetected', 'lcdMessage', 'ledSettings', 'smartDetectTypes']
549 | def subCameras = bootstrap.cameras?.collect { it.subMap(camerasKeysWhitelist) + [typeName: "motion"] }
550 |
551 | def lightsKeysWhitelist = baseWhitelist + ['isDark', 'isLightOn', 'isMotionDetected', 'isPirMotionDetected', 'lightDeviceSettings']
552 | def subLights = bootstrap.lights?.collect { it.subMap(lightsKeysWhitelist) + [typeName: "light"] }
553 |
554 | def chimesKeysWhitelist = baseWhitelist + ['cameraIds', 'volume']
555 | def subChimes = bootstrap.chimes?.collect { it.subMap(chimesKeysWhitelist) + [typeName: "chime"] }
556 |
557 | def viewersKeysWhitelist = baseWhitelist + ['liveview']
558 | def subViewers = bootstrap.viewers?.collect { it.subMap(viewersKeysWhitelist) + [typeName: "viewer"] }
559 |
560 | def viewsKeysWhitelist = baseWhitelist
561 | def subLiveviews = bootstrap.liveviews?.collect { it.subMap(viewsKeysWhitelist) + [typeName: "liveview"] }
562 |
563 | // only save the parts that we actually use
564 | def subBootstrap =
565 | [cameras: subCameras, lights: subLights, chimes: subChimes,
566 | viewers: subViewers, liveviews: subLiveviews, lastUpdateId: bootstrap.lastUpdateId]
567 |
568 | return subBootstrap
569 | }
570 |
571 | def setBootstrap(bootstrap)
572 | {
573 | state.bootstrap = bootstrap
574 | }
575 |
576 | def getBootstrap()
577 | {
578 | return state.bootstrap
579 | }
580 |
581 | def httpExec(operation, params)
582 | {
583 | def result = null
584 |
585 | //logDebug("httpExec(${operation}, ${params})")
586 |
587 | def httpClosure =
588 | { resp ->
589 | result = resp
590 | //logDebug("result.data = ${result.data}")
591 | }
592 |
593 | def httpOp
594 |
595 | switch(operation)
596 | {
597 | case "PATCH":
598 | httpOp = this.delegate.&httpPatch
599 | break
600 | case "POST":
601 | httpOp = this.delegate.&httpPost
602 | break
603 | case "GET":
604 | httpOp = this.delegate.&httpGet
605 | break
606 | }
607 |
608 | httpOp(params, httpClosure)
609 | return result
610 | }
611 |
612 | def httpExecWithAuthCheck(operation, params, throwToCaller = false)
613 | {
614 | def res
615 | try
616 | {
617 | res = httpExec(operation, params)
618 | return res
619 | }
620 | catch (Exception e)
621 | {
622 | if(e.getResponse().getStatus().toInteger() == 401)
623 | {
624 | // 401 Unauthorized
625 | try
626 | {
627 | logDebug("httpExecWithAuthCheck() auth failed. retrying...")
628 | refreshCookie()
629 |
630 | // update with new Auth token
631 | params['headers']['Cookie'] = getCookie()
632 | params['headers'][csrfTokenNameToSend()] = getCsrf()
633 |
634 | // workaround for bug?
635 | if(null == params['ignoreSSLIssues'])
636 | {
637 | params['ignoreSSLIssues']= true
638 | }
639 |
640 | res = httpExec(operation, params)
641 | return res
642 | }
643 | catch (Exception e2)
644 | {
645 | logDebug("httpExecWithAuthCheck() failed: ${e2.message}")
646 | if(throwToCaller)
647 | {
648 | throw(e2)
649 | }
650 | }
651 | }
652 | else if(e.getResponse().getStatus().toInteger() == 403)
653 | {
654 | log.error "Operation failed. Check account permissions. (${params?.body})"
655 | }
656 | else
657 | {
658 | if(throwToCaller)
659 | {
660 | throw(e)
661 | }
662 | }
663 | }
664 | }
665 |
666 |
667 | //
668 | // UniFi Protect packet handling and manipulation code
669 | //
670 |
671 | private subBytes(arr, start, length)
672 | {
673 | return arr.toList().subList(start, start + length) as byte[]
674 | }
675 |
676 | private repackHeaderAsMap(header)
677 | {
678 | def headerMap =
679 | [
680 | packetType: subBytes(header, 0, 1),
681 | payloadFormat: subBytes(header, 1, 1),
682 | deflated: subBytes(header, 2, 1),
683 | payloadSize: hubitat.helper.HexUtils.hexStringToInt(hubitat.helper.HexUtils.byteArrayToHexString(subBytes(header, 4, 4)))
684 | ]
685 | }
686 |
687 | import groovy.transform.Field
688 |
689 | def encHexMacro(string) { return string.getBytes()?.encodeHex()?.toString() }
690 |
691 | @Field String actionAdd = encHexMacro('"action":"add"')
692 | @Field String actionUpdate = encHexMacro('"action":"update"')
693 | @Field String modelKeyCamera = encHexMacro('"modelKey":"camera"')
694 | @Field String modelKeyChime = encHexMacro('"modelKey":"chime"')
695 | @Field String modelKeyEvent = encHexMacro('"modelKey":"event"')
696 | @Field String modelKeyLight = encHexMacro('"modelKey":"light"')
697 | @Field String modelKeyViewer = encHexMacro('"modelKey":"viewer"')
698 |
699 | @Field String eventValueRing = encHexMacro('"ring"')
700 | @Field String isDarkKey = encHexMacro('"isDark"')
701 | @Field String isLightOnKey = encHexMacro('"isLightOn"')
702 | @Field String isMotionDetectedKey = encHexMacro('"isMotionDetected"')
703 | @Field String isPirMotionDetectedKey = encHexMacro('"isPirMotionDetected"')
704 | @Field String isSmartDetectedKey = encHexMacro('"isSmartDetected"')
705 | @Field String lcdMessageKey = encHexMacro('"lcdMessage"')
706 | @Field String ledSettingsKey = encHexMacro('"ledSettings"')
707 | @Field String lightDeviceSettingsKey = encHexMacro('"lightDeviceSettings"')
708 | @Field String liveviewKey = encHexMacro('"liveview"')
709 | @Field String smartDetectTypesKey = encHexMacro('"smartDetectTypes"')
710 | @Field String volumeKey = encHexMacro('"volume"')
711 |
712 | def coarsePacketValidate(hexString)
713 | {
714 | // Beware: this is coarse and potentially brittle. Check here first if you are not seeing packets that you think you should!
715 |
716 | // Before doing any other processing, try to determine if this packet contains useful updates.
717 | // This is to limit the processing utilization on Hubitat since the Protect controller is so chatty.
718 |
719 | def localStr = hexString?.toLowerCase()
720 | if(!localStr) { return null }
721 |
722 | def searchList
723 |
724 | if( localStr.contains(modelKeyEvent) )
725 | {
726 | // "event"
727 | searchList = [smartDetectTypesKey, eventValueRing]
728 | }
729 |
730 | if( localStr.contains(modelKeyCamera) && localStr.contains(actionUpdate) )
731 | {
732 | // "camera" and "update"
733 | searchList = [isDarkKey, isMotionDetectedKey, isSmartDetectedKey, lcdMessageKey, ledSettingsKey]
734 | }
735 |
736 | if( localStr.contains(modelKeyChime) && localStr.contains(actionUpdate) )
737 | {
738 | // "chime" and "update"
739 | searchList = [volumeKey]
740 | }
741 |
742 | if( localStr.contains(modelKeyLight) && localStr.contains(actionUpdate) )
743 | {
744 | // "light" and "update"
745 | searchList = [isDarkKey, isLightOnKey, isMotionDetectedKey, isPirMotionDetectedKey, lightDeviceSettingsKey]
746 | }
747 |
748 | if( localStr.contains(modelKeyViewer) && localStr.contains(actionUpdate) )
749 | {
750 | // "liveview" and "update"
751 | searchList = [liveviewKey]
752 | }
753 |
754 | return searchList?.any { localStr.contains(it) }
755 | }
756 |
757 | private packetValidateAndDecode(hexString)
758 | {
759 | if(!disablePreFiltering)
760 | {
761 | if(!coarsePacketValidate(hexString))
762 | {
763 | //logDebug("dropped packet: ${new String(hubitat.helper.HexUtils.hexStringToByteArray(hexString))}")
764 | return
765 | }
766 | }
767 |
768 | // all of this is based on the packet formats described here: https://github.com/hjdhjd/unifi-protect/blob/main/src/protect-api-updates.ts
769 | def actionHeader
770 | def actionLength
771 | def dataHeader
772 | def dataLength
773 |
774 | def bytes
775 |
776 | //
777 | // first, basic packet validation
778 | //
779 |
780 | try
781 | {
782 | //logDebug("incoming message = ${hexString}")
783 | bytes = hubitat.helper.HexUtils.hexStringToByteArray(hexString)
784 |
785 | actionHeader = subBytes(bytes, 0, 8)
786 | actionLength = hubitat.helper.HexUtils.hexStringToInt(hubitat.helper.HexUtils.byteArrayToHexString(subBytes(actionHeader, 4, 4)))
787 | dataHeader = subBytes(bytes, actionHeader.size() + actionLength, 8)
788 | dataLength = hubitat.helper.HexUtils.hexStringToInt(hubitat.helper.HexUtils.byteArrayToHexString(subBytes(dataHeader, 4, 4)))
789 |
790 | def totalLength = actionHeader.size() + actionLength + dataHeader.size() + dataLength
791 | //logDebug("totalLength = ${totalLength}")
792 | //logDebug("bytes.size() = ${bytes.size()}")
793 |
794 | if(totalLength != bytes.size())
795 | {
796 | throw new Exception("Header/Packet mismatch.")
797 | }
798 | }
799 | catch (Exception e)
800 | {
801 | logDebug("packet validation failed: ${e.message}")
802 | // any error interpreted as fail
803 | return null
804 | }
805 |
806 | //
807 | // then, decode and re-pack data
808 | //
809 |
810 | try
811 | {
812 | def actionHeaderMap = repackHeaderAsMap(actionHeader)
813 | def dataHeaderMap = repackHeaderAsMap(dataHeader)
814 |
815 | def actionPacket = hubitat.helper.HexUtils.byteArrayToHexString(subBytes(bytes, actionHeader.size(), actionHeaderMap.payloadSize))
816 | def dataPacket = hubitat.helper.HexUtils.byteArrayToHexString(subBytes(bytes, actionHeader.size() + actionLength + dataHeader.size(), dataHeaderMap.payloadSize))
817 |
818 | def slurper = new groovy.json.JsonSlurper()
819 |
820 | //logDebug("actionHeaderMap = ${actionHeaderMap}")
821 | //logDebug("actionPacket = ${actionPacket}")
822 |
823 | def actionJson = actionHeaderMap.deflated?.getAt(0) ? decompress(actionPacket) : makeString(actionPacket)
824 | def actionJsonMap = slurper.parseText(actionJson?.toString())
825 |
826 | def dataJson = dataHeaderMap.deflated?.getAt(0) ? decompress(dataPacket) : makeString(dataPacket)
827 | def dataJsonMap = slurper.parseText(dataJson?.toString())
828 |
829 | def decodedPacket =
830 | [
831 | actionPacket: [actionHeader: actionHeaderMap, actionPayload: actionJsonMap],
832 | dataPacket: [dataHeader: dataHeaderMap, dataPayload: dataJsonMap]
833 | ]
834 |
835 | //logDebug("decodedPacket = ${decodedPacket}")
836 | return decodedPacket
837 | }
838 | catch (Exception e)
839 | {
840 | logDebug("packet decoding failed: ${e.message}")
841 | // any error interpreted as fail
842 | return null
843 | }
844 | }
845 |
846 | private makeString(s)
847 | {
848 | return new String(hubitat.helper.HexUtils.hexStringToByteArray(s))
849 | }
850 |
851 | import java.io.ByteArrayOutputStream
852 | import java.util.zip.Inflater
853 |
854 | private decompress(s)
855 | {
856 | // based on this example: https://dzone.com/articles/how-compress-and-uncompress
857 | def sBytes = hubitat.helper.HexUtils.hexStringToByteArray(s)
858 |
859 | Inflater inflater = new Inflater()
860 | inflater.setInput(sBytes)
861 |
862 | ByteArrayOutputStream outputStream = new ByteArrayOutputStream(sBytes.length)
863 |
864 | byte[] buffer = new byte[1024]
865 |
866 | while(!inflater.finished())
867 | {
868 | int count = inflater.inflate(buffer)
869 | outputStream.write(buffer, 0, count)
870 | }
871 |
872 | outputStream.close()
873 |
874 | def resp = new String(outputStream.toByteArray())
875 | //logDebug("decompress resp = ${resp}")
876 |
877 | return resp
878 | }
879 |
--------------------------------------------------------------------------------
/unifiProtectDoorbell:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Copyright 2020 - tomw
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
17 | -------------------------------------------
18 |
19 | Change history:
20 |
21 | 1.4.4 - tomw - Added notificationText attribute for doorbells
22 | 1.4.3 - tomw - Added adjustChimeType command. Can be used to disable and enable chimes in automations.
23 | 1.3.6 - tomw - Improved event handling
24 | 1.3.4 - tomw - Added light support and "isDark" attribute on cameras and lights
25 | 1.2.0 - tomw - Added doorbell LCD message support
26 | 1.0.0 - tomw - Initial release
27 |
28 | */
29 |
30 | metadata
31 | {
32 | definition(name: "UniFi Protect Doorbell", namespace: "tomw", author: "tomw", importUrl: "")
33 | {
34 | capability "Notification"
35 | capability "PushableButton"
36 |
37 | command "push", ["number"]
38 | command "clearNotification"
39 |
40 | command "adjustChimeType",
41 | [[name: "Chime type to set*", type:"ENUM", constraints: knownChimeTypes().keySet()],
42 | [name: "Chime duration in msec (only applies to digital)", type:"NUMBER"]]
43 |
44 | attribute "chimeDuration", "number"
45 | attribute "notificationText", "string"
46 | }
47 | }
48 |
49 | def installed()
50 | {
51 | sendEvent(name: "numberOfButtons", value: 1)
52 | }
53 |
54 | def processEvent(event)
55 | {
56 | if(!event)
57 | {
58 | return
59 | }
60 |
61 | // if we have a button push event, register it
62 | if(event.type == "ring")
63 | {
64 | push(1)
65 | }
66 |
67 | def events = [[:]]
68 |
69 | if(null != event.chimeDuration)
70 | {
71 | events += [name: "chimeDuration", value: event.chimeDuration]
72 | }
73 |
74 | // if there's no value in lcdMessage.text, then assume it's the global default
75 | events += [name: "notificationText", value: event.lcdMessage?.text ?: "WELCOME"]
76 |
77 | events.each { sendEvent(it) }
78 | }
79 |
80 | def push(number = 1)
81 | {
82 | sendEvent(name: "pushed", value: number, isStateChange: true)
83 | }
84 |
85 | def deviceNotification(text)
86 | {
87 | updateDoorbell([lcdMessage: [type: "CUSTOM_MESSAGE", text: text, resetAt: null]])
88 | }
89 |
90 | def clearNotification()
91 | {
92 | updateDoorbell([lcdMessage: [resetAt: 0]])
93 | }
94 |
95 | def adjustChimeType(String type = "none", duration = 1000)
96 | {
97 | try
98 | {
99 | if(!knownChimeTypes().keySet().contains(type))
100 | {
101 | throw new Exception("\"${type}\" chime type not supported")
102 | }
103 |
104 | def actualDuration = knownChimeTypes()?.getAt(type)
105 |
106 | if(type == "digital")
107 | {
108 | // use the command input, but bound it to the range of 1s to 10s
109 | actualDuration = ((duration < 1000) || (duration > 10000)) ? actualDuration : duration
110 | }
111 |
112 | updateDoorbell([chimeDuration: actualDuration])
113 |
114 | // request a refresh from the parent so our update is reflected
115 | parent?.refresh()
116 | }
117 | catch (Exception e)
118 | {
119 | log.warn e.message
120 | }
121 | }
122 |
123 | def knownChimeTypes()
124 | {
125 | def chimes =
126 | [
127 | "none": 0,
128 | "mechanical": 300,
129 | "digital": 1000
130 | ]
131 |
132 | return chimes
133 | }
134 |
135 | def updateDoorbell(dataMap)
136 | {
137 | try
138 | {
139 | getParent()?.httpExecWithAuthCheck("PATCH", parent?.genParamsMain(getDoorbellUrlSuffix(), new groovy.json.JsonOutput().toJson(dataMap)), true)
140 | }
141 | catch (Exception e)
142 | {
143 | log.debug "updateDoorbell(${dataMap}) failed: ${e.message}"
144 | }
145 | }
146 |
147 | def getDoorbellUrlSuffix()
148 | {
149 | def id = device.getDeviceNetworkId()?.split('-')?.getAt(0)
150 | def baseUrlSuffix = "/proxy/protect/api/cameras/" + id
151 |
152 | return baseUrlSuffix
153 | }
154 |
--------------------------------------------------------------------------------
/unifiProtectLight:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Copyright 2022 - tomw
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
17 | -------------------------------------------
18 |
19 | Change history:
20 |
21 | 1.4.5 - tomw - Bugfixes for motion on Lights
22 | 1.3.6 - tomw - Improved event handling
23 | 1.3.4 - tomw - Added light support and "isDark" attribute on cameras and lights
24 |
25 | */
26 |
27 | metadata
28 | {
29 | definition(name: "UniFi Protect Light", namespace: "tomw", author: "tomw", importUrl: "")
30 | {
31 | capability "MotionSensor"
32 | capability "Switch"
33 | capability "SwitchLevel"
34 |
35 | attribute "isDark", "enum", ["true", "false"]
36 | }
37 | }
38 |
39 | def processEvent(event)
40 | {
41 | if(!event)
42 | {
43 | return
44 | }
45 |
46 | if(null != event.isLightOn)
47 | {
48 | sendEvent(name: "switch", value: event.isLightOn ? "on" : "off")
49 | }
50 |
51 | if(null != event.lightDeviceSettings)
52 | {
53 | sendEvent(name: "level", value: brightToPercent(event.lightDeviceSettings.ledLevel))
54 | }
55 |
56 | if(null != event.isDark)
57 | {
58 | sendEvent(name: "isDark", value: event.isDark)
59 | }
60 |
61 | if(null != event.isMotionDetected)
62 | {
63 | sendEvent(name: "motion", value: event.isMotionDetected ? "active" : "inactive")
64 | }
65 |
66 | if(null != event.isPirMotionDetected)
67 | {
68 | sendEvent(name: "motion", value: event.isPirMotionDetected ? "active" : "inactive")
69 | }
70 | }
71 |
72 | private brightToPercent(bright)
73 | {
74 | def res = ((bright - 1) * 20).toInteger()
75 | return (res > 100) ? 100 : ((res < 0) ? 0 : res)
76 | }
77 |
78 | private percentToBright(percent)
79 | {
80 | def res = Math.round((percent / 20) + 1)
81 | return (res > 6) ? 6 : ((res < 1) ? 1 : res)
82 | }
83 |
84 | def on()
85 | {
86 | operateLight(true)
87 | }
88 |
89 | def off()
90 | {
91 | operateLight(false)
92 | }
93 |
94 | def operateLight(state)
95 | {
96 | updateLight([lightOnSettings: [isLedForceOn: state]])
97 | }
98 |
99 | def setLevel(level)
100 | {
101 | updateLight([lightDeviceSettings: [ledLevel: percentToBright(level)]])
102 | }
103 |
104 | def updateLight(dataMap)
105 | {
106 | try
107 | {
108 | getParent()?.httpExecWithAuthCheck("PATCH", parent?.genParamsMain(getLightUrlSuffix(), new groovy.json.JsonOutput().toJson(dataMap)), true)
109 | }
110 | catch (Exception e)
111 | {
112 | log.debug "updateLight(${dataMap}) failed: ${e.message}"
113 | }
114 | }
115 |
116 | def getLightUrlSuffix()
117 | {
118 | def id = device.getDeviceNetworkId()?.split('-')?.getAt(0)
119 | def baseUrlSuffix = "/proxy/protect/api/lights/" + id
120 |
121 | return baseUrlSuffix
122 | }
123 |
--------------------------------------------------------------------------------
/unifiProtectViewer:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Copyright 2023 - tomw
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 |
17 | -------------------------------------------
18 |
19 | Change history:
20 |
21 | 1.4.4 - tomw - Support chimes and viewports
22 |
23 | */
24 |
25 | metadata
26 | {
27 | definition(name: "UniFi Protect Viewer", namespace: "tomw", author: "tomw", importUrl: "")
28 | {
29 | command "selectLiveview", [[name:"Enter a liveview name or id*", type: "STRING"]]
30 |
31 | attribute "liveview", "string"
32 | }
33 | }
34 |
35 | def processEvent(event)
36 | {
37 | if(!event)
38 | {
39 | return
40 | }
41 |
42 | if(null != event.liveview)
43 | {
44 | def liveview = lookupLiveview(event.liveview)
45 | sendEvent(name: "liveview", value: liveview?.name ?: "unknown name")
46 | }
47 | }
48 |
49 | def lookupLiveview(liveview)
50 | {
51 | // we don't get an event on liveviews changes, because we're a viewer (not a liveview)
52 | // so this list may be stale until something calls refresh() on the parent
53 | def liveviews = parent?.getBootstrap()?.liveviews
54 |
55 | // look first for a view with this name, or else look for one with this id
56 | def actualLiveview = liveviews?.find { (liveview == it.name) ?: (liveview == it.id) }
57 |
58 | if(!actualLiveview)
59 | {
60 | log.error "liveview not found: ${liveview}"
61 | }
62 |
63 | return actualLiveview
64 | }
65 |
66 | def selectLiveview(liveview)
67 | {
68 | def actualLiveview = lookupLiveview(liveview)
69 |
70 | if(actualLiveview?.id)
71 | {
72 | updateViewer([liveview: actualLiveview.id])
73 | }
74 | }
75 |
76 | def updateViewer(dataMap)
77 | {
78 | try
79 | {
80 | getParent()?.httpExecWithAuthCheck("PATCH", parent?.genParamsMain(getViewersUrlSuffix(), new groovy.json.JsonOutput().toJson(dataMap)), true)
81 | }
82 | catch (Exception e)
83 | {
84 | log.debug "updateViewer(${dataMap}) failed: ${e.message}"
85 | }
86 | }
87 |
88 | def getViewersUrlSuffix()
89 | {
90 | def id = device.getDeviceNetworkId()?.split('-')?.getAt(0)
91 | def baseUrlSuffix = "/proxy/protect/api/viewers/" + id
92 |
93 | return baseUrlSuffix
94 | }
95 |
--------------------------------------------------------------------------------