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