├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json └── src ├── nuki-api.js ├── nuki-bridge-client.js ├── nuki-bridge-device.js ├── nuki-opener-device.js ├── nuki-platform.js └── nuki-smart-lock-device.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Local configuration 64 | config.json 65 | persist 66 | accessories 67 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Nuki", 4 | "smartlock" 5 | ] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lukas Rögner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # homebridge-nuki 2 | 3 | ## ⚠️ Deprecation notice - plugin archived 4 | 5 | Due to lack of time, this plugin will no longer be maintained. There are great plugins available for the Nuki ecosystem, such as [homebridge-nukiio](https://github.com/benzman81/homebridge-nukiio) or [homebridge-nb](https://github.com/ebaauw/homebridge-nb), which provide a richer feature set and great support by the authors. 6 | 7 | ## About 8 | 9 | This project is a homebridge plugin for Nuki devices. 10 | 11 | The device information is loaded from the local Nuki Bridge, therefore you just have to specify an API token for communication with the Nuki Bridge. 12 | 13 | ## Bridge 14 | 15 | The Nuki Bridge is exposed as a switch for rebooting (optional). 16 | 17 | ## SmartLock 18 | 19 | The Nuki SmartLock is exposed as a lock in HomeKit with support for: 20 | - Lock/Unlock/Unlatch 21 | - Door State 22 | - Status for Battery (level and low warning) 23 | 24 | Optionally, a second switch is shown in the lock that represents the latch. 25 | 26 | ## Opener 27 | 28 | The Nuki Opener is exposed as a lock in HomeKit with support for: 29 | - Unlock 30 | - Status for Low Battery 31 | 32 | Optionally, the following switches are exposed: 33 | - Ring-to-open (on/off) 34 | - Continuous Mode (on/off) 35 | 36 | Optionally, you can get doorbell notificaions for the Opener. 37 | 38 | The plugin is optimized for usage of the Home app in iOS 13, i.e. a separate accessory is exposed for RTO and continuous mode switches. 39 | 40 | ## Installation 41 | 42 | Install the plugin via npm: 43 | 44 | ```bash 45 | npm install homebridge-nuki -g 46 | ``` 47 | 48 | ## Prepare Bridge 49 | 50 | You have to enable the HTTP API on the Nuki Bridge: 51 | * Open the Nuki app 52 | * Open the menu and go to "Manage my devices" and choose the Bridge 53 | * Click on "Manage bridge" and follow the instructions (press the button on the bridge for at least 10 seconds) 54 | * After the bridge management interface is loaded, click on the bridge icon and enable the switch for "HTTP API" 55 | * The IP address, port and API token are shown (you need them for the configuration of the plugin) 56 | 57 | **Important**: If you already use other software to communicate with the Nuki Bridge, or you changed the host name or port of this plugin, make sure that there is at least one free slot for the callback registration (which is used to get notifications from the Nuki Bridge). To list all registered callbacks, make an HTTP GET request to `http://:8080/callback/list?token=`. The Nuki Bridge only allows 3 registered callback adresses. If there is no free slot, remove a registered callback by sending an HTTP GET to `http://:8080/callback/remove?id=&token=`. 58 | 59 | ## Find Nuki IDs of the devices 60 | 61 | Start homebridge with the plugin installed, however, do not provide any devices in the `devices` array. The plugin will print out all devices with their name, type and corresponding `nukiId`. 62 | 63 | ## Configuration 64 | 65 | ```json 66 | { 67 | "platforms": [ 68 | { 69 | "platform": "NukiPlatform", 70 | "hostNameOrIpAddress": "", 71 | "hostCallbackApiPort": 40506, 72 | "bridgeIpAddress": "", 73 | "bridgeApiPort": 8080, 74 | "bridgeApiToken": "", 75 | "bridgeRebootSwitch": false, 76 | "devices": [ 77 | { 78 | "nukiId": 0, 79 | "isDoorSensorEnabled": false, 80 | "isRingToOpenEnabled": false, 81 | "isContinuousModeEnabled": false, 82 | "isDoorbellEnabled": false, 83 | "suppressDoorbellOnRingToOpen": false, 84 | "suppressDoorbellInContinuousMode": false, 85 | "isSingleAccessoryModeEnabled": false, 86 | "unlatchFromLockedToUnlocked": false, 87 | "unlatchFromUnlockedToUnlocked": false, 88 | "lockFromLockedToLocked": false, 89 | "unlatchLock": false, 90 | "unlatchLockPreventUnlatchIfLocked": false, 91 | "leaveOpen": false, 92 | "defaultLockName": "Lock", 93 | "defaultLatchName": "Latch" 94 | } 95 | ], 96 | "isApiEnabled": false, 97 | "apiPort": 40011, 98 | "apiToken": "" 99 | } 100 | ] 101 | } 102 | ``` 103 | 104 | **hostNameOrIpAddress**: The IP address or host name of the device you run the plugin on. This information is required to register a callback for notifications. 105 | 106 | **hostCallbackApiPort** (optional): The port that is opened on the device you runt the plugin on. Defaults to `40506`, please change this setting of the port is already in use. 107 | 108 | **bridgeIpAddress**: The IP address of your Nuki Bridge. 109 | 110 | **bridgeApiPort** (optional): The port on which the API runs on your Nuki Bridge. Defaults to `8080`, please change this setting if you use a different port on the Nuki Bridge for the API. 111 | 112 | **bridgeApiToken**: The token for communication with the Bridge API. Can be configured in the Nuki App. 113 | 114 | **bridgeRebootSwitch**: If set to true, the Nuki Bridge is exposed as a switch for rebooting. 115 | 116 | **devices**: Array of all your Nuki devices that the plugin should expose. 117 | 118 | **nukiId**: The ID of the device (provide as number, not as string). 119 | 120 | **isDoorSensorEnabled**: If set to true, a contact snesor is exposed for the door state. (only for SmartLock) 121 | 122 | **isRingToOpenEnabled**: If set to true, a switch is exposed for the ring-to-open function. (only for Opener) 123 | 124 | **isContinuousModeEnabled**: If set to true, a switch is exposed for the continuous mode. (only for Opener) 125 | 126 | **isDoorbellEnabled**: If set to true, you get doorbell notifications via the Apple Home app. (only for Opener) 127 | 128 | **suppressDoorbellOnRingToOpen**: If set to true, you do not get doorbell notifications via the Apple Home app when ring-to-open is enabled. (only for Opener) 129 | 130 | **suppressDoorbellInContinuousMode**: If set to true, you do not get doorbell notifications via the Apple Home app when continuous mode is enabled. (only for Opener) 131 | 132 | **isSingleAccessoryModeEnabled**: By default, the ring-to-open and continuous mode switches are placed in a separate accessory (Opener only, works best in the Apple Home app), and the lock and door sensor are also played in a separate accessory (SmartLock only). If this value is set to true, those services are all exposed in a single accessory. 133 | 134 | **unlatchFromLockedToUnlocked**: If set to true, the door is unlatched when you switch from "locked" to "unlocked" in the Home app. If set to false, the door is just unlocked when you switch from "locked" to "unlocked" in the Home app. (only for SmartLock) 135 | 136 | **unlatchFromUnlockedToUnlocked**: If set to true, the door is unlatched when you switch from "unlocked" to "unlocked" [1] in the Home app (this move is valid and works in the Home app, just hold down the switch, swipe it to "locked" and then "unlocked" without releasing your finger - do not release the finger until you reached the "unlocked" position again). If set to false, nothing is done when you switch from "unlocked" to "unlocked" in the Home app. [2] (only for SmartLock) 137 | 138 | **lockFromLockedToLocked**: If set to true, the door can be locked again if the lock is already locked (e.g. 360 degree to 720 degree). (only for SmartLock) 139 | 140 | **unlatchLock**: If set to true, a second lock switch is exposed for unlatching the smart lock. (only for SmartLock) 141 | 142 | **unlatchLockPreventUnlatchIfLocked**: If set to true, the second lock (**unlatchLock** has to be true) can only operate if the SmartLock is unlocked. (only for SmartLock) 143 | 144 | **leaveOpen** (optional): If set to true, the opener don't lock the accessory in HomeKit automatically after it has been set to "unlock". Use this parameter only if you want to set the "lock" state by an automation. (only for Opener) 145 | 146 | **defaultLockName** (optional): Lets you customize the name of the lock mechanism. Useful for the Alexa plugin, which does not detect changes of service names in HomeKit. Defaults to `Lock`. (only for SmartLock) 147 | 148 | **defaultLatchName** (optional): Lets you customize the name of the unlatch mechanism. Useful for the Alexa plugin, which does not detect changes of service names in HomeKit. Defaults to `Latch`. (only for SmartLock) 149 | 150 | **isApiEnabled** (optional): Enables an HTTP API for controlling devices. Defaults to `false`. See **API** for more information. 151 | 152 | **apiPort** (optional): The port that the API (if enabled) runs on. Defaults to `40011`, please change this setting of the port is already in use. 153 | 154 | **apiToken** (optional): The token that has to be included in each request of the API. Is required if the API is enabled and has no default value. 155 | 156 | ## API 157 | 158 | This plugin also provides an HTTP API to control some features of the Nuki devices. It has been created so that you can further automate the system with HomeKit shortcuts. Starting with iOS 13, you can use shortcuts for HomeKit automation. Those automations that are executed on the HomeKit coordinator (i.e. iPad, AppleTV or HomePod) also support HTTP requests, which means you can lock your Nuki devices (e.g. when leaving home) without the security question. WARNING: This plugin only exposes the lock action as an API action, as the unlock action could potentially open your door if you made mistakes in the shortcuts. 159 | 160 | If the API is enabled, it can be reached at the specified port on the host of this plugin. 161 | ``` 162 | http://: 163 | ``` 164 | 165 | The token has to be specified as value of the `Authorization` header on each request: 166 | ``` 167 | Authorization: 168 | ``` 169 | 170 | ### API - Get values of device 171 | 172 | Use the `devices//` endpoint to retrieve a single value of a device. The HTTP method has to be `GET`: 173 | ``` 174 | http://:/devices// 175 | ``` 176 | 177 | The response is a plain text response (easier to handle in HomeKit shortcuts), the following property names are supported: 178 | 179 | * **state** The lock state of the device (possible values: `locked`, `unlocked`, `unlatched`, `jammed`) 180 | 181 | Use the `devices/` endpoint to retrieve all values of a device. The HTTP method has to be `GET`: 182 | ``` 183 | http://:/devices/ 184 | ``` 185 | 186 | The response is a JSON object containing all values: 187 | ``` 188 | { 189 | "state": "locked" 190 | } 191 | ``` 192 | 193 | ### API - Set values of device 194 | 195 | Use the `devices/` endpoint to set values of a device. The HTTP method has to be `POST`: 196 | ``` 197 | http://:/devices/ 198 | ``` 199 | 200 | The body of the request has to be JSON containing the new values: 201 | ``` 202 | { 203 | "": 204 | } 205 | ``` 206 | 207 | The following property names are supported: 208 | 209 | * **state** The lock state (possible values: `locked` to lock the door) 210 | 211 | _______________________________ 212 | [1] Also works with Siri, you can ask to unlock devices that are already unlocked. 213 | 214 | [2] If you use this mode of operation, the separate `unlatchLock` is not really necessary. Use `unlatchFromLockedToUnlocked: true`, `unlatchFromUnlockedToUnlocked: true` and `unlatchLock: false` to mimic the HomeKit behavior of the lock. -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | const NukiPlatform = require('./src/nuki-platform'); 3 | 4 | /** 5 | * Defines the export of the plugin entry point. 6 | * @param homebridge The homebridge API that contains all classes, objects and functions for communicating with HomeKit. 7 | */ 8 | module.exports = function (homebridge) { 9 | homebridge.registerPlatform('homebridge-nuki', 'NukiPlatform', NukiPlatform, true); 10 | } 11 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-nuki", 3 | "version": "0.7.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "ajv": { 8 | "version": "6.10.2", 9 | "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", 10 | "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", 11 | "requires": { 12 | "fast-deep-equal": "^2.0.1", 13 | "fast-json-stable-stringify": "^2.0.0", 14 | "json-schema-traverse": "^0.4.1", 15 | "uri-js": "^4.2.2" 16 | } 17 | }, 18 | "asn1": { 19 | "version": "0.2.4", 20 | "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", 21 | "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", 22 | "requires": { 23 | "safer-buffer": "~2.1.0" 24 | } 25 | }, 26 | "assert-plus": { 27 | "version": "1.0.0", 28 | "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", 29 | "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" 30 | }, 31 | "asynckit": { 32 | "version": "0.4.0", 33 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 34 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" 35 | }, 36 | "aws-sign2": { 37 | "version": "0.7.0", 38 | "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", 39 | "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" 40 | }, 41 | "aws4": { 42 | "version": "1.8.0", 43 | "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", 44 | "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" 45 | }, 46 | "bcrypt-pbkdf": { 47 | "version": "1.0.2", 48 | "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", 49 | "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", 50 | "requires": { 51 | "tweetnacl": "^0.14.3" 52 | } 53 | }, 54 | "caseless": { 55 | "version": "0.12.0", 56 | "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", 57 | "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" 58 | }, 59 | "combined-stream": { 60 | "version": "1.0.8", 61 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 62 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 63 | "requires": { 64 | "delayed-stream": "~1.0.0" 65 | } 66 | }, 67 | "core-util-is": { 68 | "version": "1.0.2", 69 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", 70 | "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" 71 | }, 72 | "dashdash": { 73 | "version": "1.14.1", 74 | "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", 75 | "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", 76 | "requires": { 77 | "assert-plus": "^1.0.0" 78 | } 79 | }, 80 | "delayed-stream": { 81 | "version": "1.0.0", 82 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 83 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" 84 | }, 85 | "ecc-jsbn": { 86 | "version": "0.1.2", 87 | "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", 88 | "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", 89 | "requires": { 90 | "jsbn": "~0.1.0", 91 | "safer-buffer": "^2.1.0" 92 | } 93 | }, 94 | "extend": { 95 | "version": "3.0.2", 96 | "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", 97 | "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" 98 | }, 99 | "extsprintf": { 100 | "version": "1.3.0", 101 | "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", 102 | "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" 103 | }, 104 | "fast-deep-equal": { 105 | "version": "2.0.1", 106 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", 107 | "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" 108 | }, 109 | "fast-json-stable-stringify": { 110 | "version": "2.0.0", 111 | "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", 112 | "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" 113 | }, 114 | "forever-agent": { 115 | "version": "0.6.1", 116 | "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", 117 | "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" 118 | }, 119 | "form-data": { 120 | "version": "2.3.3", 121 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", 122 | "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", 123 | "requires": { 124 | "asynckit": "^0.4.0", 125 | "combined-stream": "^1.0.6", 126 | "mime-types": "^2.1.12" 127 | } 128 | }, 129 | "getpass": { 130 | "version": "0.1.7", 131 | "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", 132 | "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", 133 | "requires": { 134 | "assert-plus": "^1.0.0" 135 | } 136 | }, 137 | "har-schema": { 138 | "version": "2.0.0", 139 | "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", 140 | "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" 141 | }, 142 | "har-validator": { 143 | "version": "5.1.3", 144 | "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", 145 | "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", 146 | "requires": { 147 | "ajv": "^6.5.5", 148 | "har-schema": "^2.0.0" 149 | } 150 | }, 151 | "http-signature": { 152 | "version": "1.2.0", 153 | "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", 154 | "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", 155 | "requires": { 156 | "assert-plus": "^1.0.0", 157 | "jsprim": "^1.2.2", 158 | "sshpk": "^1.7.0" 159 | } 160 | }, 161 | "is-typedarray": { 162 | "version": "1.0.0", 163 | "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", 164 | "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" 165 | }, 166 | "isstream": { 167 | "version": "0.1.2", 168 | "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", 169 | "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" 170 | }, 171 | "jsbn": { 172 | "version": "0.1.1", 173 | "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", 174 | "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" 175 | }, 176 | "json-schema": { 177 | "version": "0.2.3", 178 | "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", 179 | "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" 180 | }, 181 | "json-schema-traverse": { 182 | "version": "0.4.1", 183 | "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", 184 | "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" 185 | }, 186 | "json-stringify-safe": { 187 | "version": "5.0.1", 188 | "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", 189 | "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" 190 | }, 191 | "jsprim": { 192 | "version": "1.4.1", 193 | "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", 194 | "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", 195 | "requires": { 196 | "assert-plus": "1.0.0", 197 | "extsprintf": "1.3.0", 198 | "json-schema": "0.2.3", 199 | "verror": "1.10.0" 200 | } 201 | }, 202 | "mime-db": { 203 | "version": "1.40.0", 204 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", 205 | "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" 206 | }, 207 | "mime-types": { 208 | "version": "2.1.24", 209 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", 210 | "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", 211 | "requires": { 212 | "mime-db": "1.40.0" 213 | } 214 | }, 215 | "oauth-sign": { 216 | "version": "0.9.0", 217 | "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", 218 | "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" 219 | }, 220 | "performance-now": { 221 | "version": "2.1.0", 222 | "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", 223 | "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" 224 | }, 225 | "psl": { 226 | "version": "1.4.0", 227 | "resolved": "https://registry.npmjs.org/psl/-/psl-1.4.0.tgz", 228 | "integrity": "sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==" 229 | }, 230 | "punycode": { 231 | "version": "2.1.1", 232 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", 233 | "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" 234 | }, 235 | "qs": { 236 | "version": "6.5.2", 237 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", 238 | "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" 239 | }, 240 | "request": { 241 | "version": "2.88.0", 242 | "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", 243 | "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", 244 | "requires": { 245 | "aws-sign2": "~0.7.0", 246 | "aws4": "^1.8.0", 247 | "caseless": "~0.12.0", 248 | "combined-stream": "~1.0.6", 249 | "extend": "~3.0.2", 250 | "forever-agent": "~0.6.1", 251 | "form-data": "~2.3.2", 252 | "har-validator": "~5.1.0", 253 | "http-signature": "~1.2.0", 254 | "is-typedarray": "~1.0.0", 255 | "isstream": "~0.1.2", 256 | "json-stringify-safe": "~5.0.1", 257 | "mime-types": "~2.1.19", 258 | "oauth-sign": "~0.9.0", 259 | "performance-now": "^2.1.0", 260 | "qs": "~6.5.2", 261 | "safe-buffer": "^5.1.2", 262 | "tough-cookie": "~2.4.3", 263 | "tunnel-agent": "^0.6.0", 264 | "uuid": "^3.3.2" 265 | } 266 | }, 267 | "safe-buffer": { 268 | "version": "5.2.0", 269 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", 270 | "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" 271 | }, 272 | "safer-buffer": { 273 | "version": "2.1.2", 274 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 275 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 276 | }, 277 | "sshpk": { 278 | "version": "1.16.1", 279 | "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", 280 | "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", 281 | "requires": { 282 | "asn1": "~0.2.3", 283 | "assert-plus": "^1.0.0", 284 | "bcrypt-pbkdf": "^1.0.0", 285 | "dashdash": "^1.12.0", 286 | "ecc-jsbn": "~0.1.1", 287 | "getpass": "^0.1.1", 288 | "jsbn": "~0.1.0", 289 | "safer-buffer": "^2.0.2", 290 | "tweetnacl": "~0.14.0" 291 | } 292 | }, 293 | "tough-cookie": { 294 | "version": "2.4.3", 295 | "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", 296 | "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", 297 | "requires": { 298 | "psl": "^1.1.24", 299 | "punycode": "^1.4.1" 300 | }, 301 | "dependencies": { 302 | "punycode": { 303 | "version": "1.4.1", 304 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", 305 | "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" 306 | } 307 | } 308 | }, 309 | "tunnel-agent": { 310 | "version": "0.6.0", 311 | "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 312 | "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", 313 | "requires": { 314 | "safe-buffer": "^5.0.1" 315 | } 316 | }, 317 | "tweetnacl": { 318 | "version": "0.14.5", 319 | "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", 320 | "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" 321 | }, 322 | "uri-js": { 323 | "version": "4.2.2", 324 | "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", 325 | "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", 326 | "requires": { 327 | "punycode": "^2.1.0" 328 | } 329 | }, 330 | "uuid": { 331 | "version": "3.3.3", 332 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", 333 | "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" 334 | }, 335 | "verror": { 336 | "version": "1.10.0", 337 | "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", 338 | "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", 339 | "requires": { 340 | "assert-plus": "^1.0.0", 341 | "core-util-is": "1.0.2", 342 | "extsprintf": "^1.2.0" 343 | } 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-nuki", 3 | "version": "0.11.1", 4 | "description": "Plugin for using Nuki devices in homebridge.", 5 | "license": "MIT", 6 | "keywords": [ 7 | "homebridge-plugin", 8 | "homebridge", 9 | "homebridge-nuki", 10 | "nuki" 11 | ], 12 | "dependencies": { 13 | "request": "^2.88.0" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/lukasroegner/homebridge-nuki.git" 18 | }, 19 | "bugs": { 20 | "url": "http://github.com/lukasroegner/homebridge-nuki/issues" 21 | }, 22 | "engines": { 23 | "node": ">=0.12.0", 24 | "homebridge": ">=0.2.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/nuki-api.js: -------------------------------------------------------------------------------- 1 | 2 | const http = require('http'); 3 | const url = require('url'); 4 | 5 | /** 6 | * Represents the API. 7 | * @param platform The NukiPlatform instance. 8 | */ 9 | function NukiApi(platform) { 10 | const api = this; 11 | 12 | // Sets the platform 13 | api.platform = platform; 14 | 15 | // Checks if all required information is provided 16 | if (!api.platform.config.apiPort) { 17 | api.platform.log('No API port provided.'); 18 | return; 19 | } 20 | if (!api.platform.config.apiToken) { 21 | api.platform.log('No API token provided.'); 22 | return; 23 | } 24 | 25 | // Starts the server 26 | try { 27 | http.createServer(function (request, response) { 28 | const payload = []; 29 | 30 | // Subscribes for events of the request 31 | request.on('error', function () { 32 | api.platform.log('API - Error received.'); 33 | }).on('data', function (chunk) { 34 | payload.push(chunk); 35 | }).on('end', function () { 36 | 37 | // Subscribes to errors when sending the response 38 | response.on('error', function () { 39 | api.platform.log('API - Error sending the response.'); 40 | }); 41 | 42 | // Validates the token 43 | if (!request.headers['authorization']) { 44 | api.platform.log('Authorization header missing.'); 45 | response.statusCode = 401; 46 | response.end(); 47 | return; 48 | } 49 | if (request.headers['authorization'] !== api.platform.config.apiToken) { 50 | api.platform.log('Token invalid.'); 51 | response.statusCode = 401; 52 | response.end(); 53 | return; 54 | } 55 | 56 | // Validates the endpoint 57 | const endpoint = api.getEndpoint(request.url); 58 | if (!endpoint) { 59 | api.platform.log('No endpoint found.'); 60 | response.statusCode = 404; 61 | response.end(); 62 | return; 63 | } 64 | 65 | // Validates the body 66 | let body = null; 67 | if (payload && payload.length > 0) { 68 | body = JSON.parse(Buffer.concat(payload).toString()); 69 | } 70 | 71 | // Performs the action based on the endpoint and method 72 | switch (endpoint.name) { 73 | case 'propertyByDevice': 74 | switch (request.method) { 75 | case 'GET': 76 | api.handleGetPropertyByDevice(endpoint, response); 77 | return; 78 | } 79 | break; 80 | 81 | case 'device': 82 | switch (request.method) { 83 | case 'GET': 84 | api.handleGetDevice(endpoint, response); 85 | return; 86 | 87 | case 'POST': 88 | api.handlePostDevice(endpoint, body, response); 89 | return; 90 | } 91 | break; 92 | } 93 | 94 | api.platform.log('No action matched.'); 95 | response.statusCode = 404; 96 | response.end(); 97 | }); 98 | }).listen(api.platform.config.apiPort, "0.0.0.0"); 99 | api.platform.log('API started.'); 100 | } catch (e) { 101 | api.platform.log('API could not be started: ' + JSON.stringify(e)); 102 | } 103 | } 104 | 105 | /** 106 | * Handles requests to GET /devices/{nukiId}/{propertyName}. 107 | * @param endpoint The endpoint information. 108 | * @param response The response object. 109 | */ 110 | NukiApi.prototype.handleGetPropertyByDevice = function (endpoint, response) { 111 | const api = this; 112 | 113 | // Checks if the device exists 114 | const apiDevice = api.platform.apiConfig.find(function(d) { return d.nukiId === endpoint.nukiId; }); 115 | if (!apiDevice) { 116 | api.platform.log('Device not found.'); 117 | response.statusCode = 400; 118 | response.end(); 119 | return; 120 | } 121 | 122 | // Gets the value based on property name 123 | switch (endpoint.propertyName) { 124 | 125 | case 'state': 126 | response.setHeader('Content-Type', 'text/plain'); 127 | if (apiDevice.deviceType == 0) { 128 | if (apiDevice.lastKnownState.state == 1) { 129 | response.write('locked'); 130 | } 131 | if (apiDevice.lastKnownState.state == 3) { 132 | response.write('unlocked'); 133 | } 134 | if (apiDevice.lastKnownState.state == 5) { 135 | response.write('unlatched'); 136 | } 137 | if (apiDevice.lastKnownState.state == 254) { 138 | response.write('jammed'); 139 | } 140 | } 141 | if (apiDevice.deviceType == 2) { 142 | if (apiDevice.lastKnownState.state == 1 || apiDevice.lastKnownState.state == 3) { 143 | response.write('locked'); 144 | } 145 | if (apiDevice.lastKnownState.state == 5 || apiDevice.lastKnownState.state == 7) { 146 | response.write('unlatched'); 147 | } 148 | } 149 | response.statusCode = 200; 150 | response.end(); 151 | break; 152 | 153 | default: 154 | api.platform.log('Property not found.'); 155 | response.statusCode = 400; 156 | response.end(); 157 | return; 158 | } 159 | } 160 | 161 | /** 162 | * Handles requests to GET /devices/{nukiId}. 163 | * @param endpoint The endpoint information. 164 | * @param response The response object. 165 | */ 166 | NukiApi.prototype.handleGetDevice = function (endpoint, response) { 167 | const api = this; 168 | 169 | // Checks if the device exists 170 | const apiDevice = api.platform.apiConfig.find(function(d) { return d.nukiId === endpoint.nukiId; }); 171 | if (!apiDevice) { 172 | api.platform.log('Device not found.'); 173 | response.statusCode = 400; 174 | response.end(); 175 | return; 176 | } 177 | 178 | // Gets all properties 179 | const responseObject = {}; 180 | if (apiDevice.deviceType == 0) { 181 | if (apiDevice.lastKnownState.state == 1) { 182 | responseObject.state = 'locked'; 183 | } 184 | if (apiDevice.lastKnownState.state == 3) { 185 | responseObject.state = 'unlocked'; 186 | } 187 | if (apiDevice.lastKnownState.state == 5) { 188 | responseObject.state = 'unlatched'; 189 | } 190 | if (apiDevice.lastKnownState.state == 254) { 191 | responseObject.state = 'jammed'; 192 | } 193 | } 194 | if (apiDevice.deviceType == 2) { 195 | if (apiDevice.lastKnownState.state == 1 || apiDevice.lastKnownState.state == 3) { 196 | responseObject.state = 'locked'; 197 | } 198 | if (apiDevice.lastKnownState.state == 5 || apiDevice.lastKnownState.state == 7) { 199 | responseObject.state = 'unlatched'; 200 | } 201 | } 202 | 203 | // Writes the response 204 | response.setHeader('Content-Type', 'application/json'); 205 | response.write(JSON.stringify(responseObject)); 206 | response.statusCode = 200; 207 | response.end(); 208 | } 209 | 210 | /** 211 | * Handles requests to POST /devices/{nukiId}. 212 | * @param endpoint The endpoint information. 213 | * @param body The body of the request. 214 | * @param response The response object. 215 | */ 216 | NukiApi.prototype.handlePostDevice = function (endpoint, body, response) { 217 | const api = this; 218 | 219 | // Checks if the device exists 220 | const apiDevice = api.platform.apiConfig.find(function(d) { return d.nukiId === endpoint.nukiId; }); 221 | if (!apiDevice) { 222 | api.platform.log('Device not found.'); 223 | response.statusCode = 400; 224 | response.end(); 225 | return; 226 | } 227 | 228 | // Validates the content 229 | if (!body) { 230 | api.platform.log('Body invalid.'); 231 | response.statusCode = 400; 232 | response.end(); 233 | return; 234 | } 235 | 236 | // Sets the new value 237 | const promises = []; 238 | for (let propertyName in body) { 239 | const devicePropertyValue = body[propertyName]; 240 | switch (propertyName) { 241 | case 'state': 242 | if (devicePropertyValue === 'locked' && apiDevice.deviceType == 0) { 243 | promises.push(new Promise(function (resolve, reject) { 244 | api.platform.log(apiDevice.nukiId + ' - Lock via API'); 245 | api.platform.client.send('/lockAction?nukiId=' + apiDevice.nukiId + '&deviceType=0&action=2', function (actionSuccess, actionBody) { 246 | if (actionSuccess && actionBody.success) { 247 | resolve(); 248 | } else { 249 | reject(); 250 | } 251 | }); 252 | })); 253 | } 254 | break; 255 | } 256 | } 257 | 258 | // Writes the response 259 | Promise.all(promises).then(function() { 260 | response.statusCode = 200; 261 | response.end(); 262 | }, function() { 263 | api.platform.log('Error while setting value.'); 264 | response.statusCode = 400; 265 | response.end(); 266 | }); 267 | } 268 | 269 | /** 270 | * Gets the endpoint information based on the URL. 271 | * @param uri The uri of the request. 272 | * @returns Returns the endpoint information. 273 | */ 274 | NukiApi.prototype.getEndpoint = function (uri) { 275 | 276 | // Parses the request path 277 | const uriParts = url.parse(uri); 278 | 279 | // Checks if the URL matches the devices endpoint with property name 280 | let uriMatch = /\/devices\/(.+)\/(.+)/g.exec(uriParts.pathname); 281 | if (uriMatch && uriMatch.length === 3) { 282 | return { 283 | name: 'propertyByDevice', 284 | nukiId: parseInt(uriMatch[1]), 285 | propertyName: decodeURI(uriMatch[2]) 286 | }; 287 | } 288 | 289 | // Checks if the URL matches the devices endpoint without property name 290 | uriMatch = /\/devices\/(.+)/g.exec(uriParts.pathname); 291 | if (uriMatch && uriMatch.length === 2) { 292 | return { 293 | name: 'device', 294 | nukiId: parseInt(uriMatch[1]) 295 | }; 296 | } 297 | 298 | // Returns null as no endpoint matched. 299 | return null; 300 | } 301 | 302 | /** 303 | * Defines the export of the file. 304 | */ 305 | module.exports = NukiApi; 306 | -------------------------------------------------------------------------------- /src/nuki-bridge-client.js: -------------------------------------------------------------------------------- 1 | 2 | const request = require('request'); 3 | 4 | /** 5 | * Represents the client for communicating with the Nuki Bridge. 6 | * @param platform The NukiPlatform instance. 7 | */ 8 | function NukiBridgeClient(platform) { 9 | const client = this; 10 | 11 | // Sets the platform for further use 12 | client.platform = platform; 13 | 14 | // Initializes the queue, which is used to perform sequential calls to the Bridge API 15 | client.queue = []; 16 | client.lastRequestTimestamp = null; 17 | client.isExecutingRequest = false; 18 | } 19 | 20 | /** 21 | * Sends a request to the Nuki Bridge. 22 | * @param uriPath The endpoint of the Bridge that is to be called. 23 | * @param callback The callback that contains a result. The result contains a success indicator and the body. 24 | */ 25 | NukiBridgeClient.prototype.send = function (uriPath, callback) { 26 | const client = this; 27 | 28 | // Adds the request to the queue 29 | client.queue.push({ uriPath: uriPath, callback: callback, retryCount: 0 }); 30 | 31 | // Starts processing the queue 32 | client.process(); 33 | } 34 | 35 | /** 36 | * Check if the queue contains elements that can be sent to the Bridge API. 37 | */ 38 | NukiBridgeClient.prototype.process = function () { 39 | const client = this; 40 | 41 | // Checks if the bridge client is currently executing a request 42 | if (client.isExecutingRequest) { 43 | return; 44 | } 45 | 46 | // Checks if the queue has items to process 47 | if (client.queue.length === 0) { 48 | return; 49 | } 50 | 51 | // Checks if the last request has been executed within the request buffer 52 | if (client.lastRequestTimestamp && new Date().getTime() - client.lastRequestTimestamp < client.platform.config.requestBuffer) { 53 | setTimeout(function () { client.process(); }, Math.max(100, client.platform.config.requestBuffer - (new Date().getTime() - client.lastRequestTimestamp))); 54 | return; 55 | } 56 | 57 | // Starts executing the request 58 | client.isExecutingRequest = true; 59 | 60 | // Checks if all required information is provided 61 | if (!client.platform.config.bridgeIpAddress) { 62 | client.platform.log('No bridge IP address provided.'); 63 | return; 64 | } 65 | if (!client.platform.config.bridgeApiToken) { 66 | client.platform.log('No API token for the bridge provided.'); 67 | return; 68 | } 69 | 70 | // Sends out the request 71 | const item = client.queue[0]; 72 | try { 73 | request({ 74 | uri: 'http://' + client.platform.config.bridgeIpAddress + ':' + client.platform.config.bridgeApiPort + item.uriPath + (item.uriPath.indexOf('?') == -1 ? '?' : '&') + 'token=' + client.platform.config.bridgeApiToken, 75 | method: 'GET', 76 | json: true, 77 | rejectUnauthorized: false 78 | }, function (error, response, body) { 79 | 80 | // Checks if the API returned a positive result 81 | if (error || response.statusCode != 200 || !body) { 82 | if (error) { 83 | client.platform.log('Error while communicating with the Nuki Bridge. Error: ' + error); 84 | } else if (response.statusCode != 200) { 85 | client.platform.log('Error while communicating with the Nuki Bridge. Status Code: ' + response.statusCode); 86 | } else if (!body) { 87 | client.platform.log('Error while communicating with the Nuki Bridge. Could not get body from response: ' + JSON.stringify(body)); 88 | } 89 | 90 | // Checks the retry count 91 | item.retryCount = item.retryCount + 1; 92 | if (item.retryCount >= client.platform.config.requestRetryCount) { 93 | client.queue.shift(); 94 | item.callback(false); 95 | } 96 | 97 | // Stops executing the request 98 | client.lastRequestTimestamp = new Date().getTime(); 99 | client.isExecutingRequest = false; 100 | client.process(); 101 | return; 102 | } 103 | 104 | // Executes the callback 105 | client.queue.shift(); 106 | item.callback(true, body); 107 | 108 | // Stops executing the request 109 | client.lastRequestTimestamp = new Date().getTime(); 110 | client.isExecutingRequest = false; 111 | client.process(); 112 | }); 113 | } catch (e) { 114 | client.platform.log('Error while communicating with the Nuki Bridge. Exception: ' + JSON.stringify(e)); 115 | 116 | // Checks the retry count 117 | item.retryCount = item.retryCount + 1; 118 | if (item.retryCount >= client.platform.config.requestRetryCount) { 119 | client.queue.shift(); 120 | item.callback(false); 121 | } 122 | 123 | // Stops executing the request 124 | client.lastRequestTimestamp = new Date().getTime(); 125 | client.isExecutingRequest = false; 126 | client.process(); 127 | } 128 | } 129 | 130 | /** 131 | * Defines the export of the file. 132 | */ 133 | module.exports = NukiBridgeClient; 134 | -------------------------------------------------------------------------------- /src/nuki-bridge-device.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents the physical Nuki Bridge device. 4 | * @param platform The NukiPlatform instance. 5 | */ 6 | function NukiBridgeDevice(platform) { 7 | const device = this; 8 | const { UUIDGen, Accessory, Characteristic, Service } = platform; 9 | 10 | // Sets the bridge IP address 11 | device.bridgeIpAddress = platform.config.bridgeIpAddress; 12 | 13 | // Gets all accessories from the platform that match the Bridge IP address 14 | let unusedDeviceAccessories = platform.accessories.filter(function(a) { return a.context.bridgeIpAddress === platform.config.bridgeIpAddress; }); 15 | let newDeviceAccessories = []; 16 | let deviceAccessories = []; 17 | 18 | // Gets the switch accessory 19 | let switchAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'SwitchAccessory'; }); 20 | if (switchAccessory) { 21 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(switchAccessory), 1); 22 | } else { 23 | platform.log('Adding new accessory for Bridge with IP address ' + platform.config.bridgeIpAddress + ' and kind SwitchAccessory.'); 24 | switchAccessory = new Accessory('Bridge', UUIDGen.generate(platform.config.bridgeIpAddress + 'SwitchAccessory')); 25 | switchAccessory.context.bridgeIpAddress = platform.config.bridgeIpAddress; 26 | switchAccessory.context.kind = 'SwitchAccessory'; 27 | newDeviceAccessories.push(switchAccessory); 28 | } 29 | deviceAccessories.push(switchAccessory); 30 | 31 | // Registers the newly created accessories 32 | platform.api.registerPlatformAccessories(platform.pluginName, platform.platformName, newDeviceAccessories); 33 | 34 | // Removes all unused accessories 35 | for (let i = 0; i < unusedDeviceAccessories.length; i++) { 36 | const unusedDeviceAccessory = unusedDeviceAccessories[i]; 37 | platform.log('Removing unused accessory for Bridge with IP address ' + platform.config.bridgeIpAddress + ' and kind ' + unusedDeviceAccessory.context.kind + '.'); 38 | platform.accessories.splice(platform.accessories.indexOf(unusedDeviceAccessory), 1); 39 | } 40 | platform.api.unregisterPlatformAccessories(platform.pluginName, platform.platformName, unusedDeviceAccessories); 41 | 42 | // Updates the accessory information 43 | for (let i = 0; i < deviceAccessories.length; i++) { 44 | const deviceAccessory = deviceAccessories[i]; 45 | let accessoryInformationService = deviceAccessory.getService(Service.AccessoryInformation); 46 | if (!accessoryInformationService) { 47 | accessoryInformationService = deviceAccessory.addService(Service.AccessoryInformation); 48 | } 49 | accessoryInformationService 50 | .setCharacteristic(Characteristic.Manufacturer, 'Nuki') 51 | .setCharacteristic(Characteristic.Model, 'Bridge') 52 | .setCharacteristic(Characteristic.SerialNumber, platform.config.bridgeIpAddress); 53 | } 54 | 55 | // Updates the switch 56 | let switchService = switchAccessory.getService(Service.Switch); 57 | if (!switchService) { 58 | switchService = switchAccessory.addService(Service.Switch); 59 | } 60 | switchService.setCharacteristic(Characteristic.On, false); 61 | 62 | // Subscribes for changes of the switch 63 | switchService.getCharacteristic(Characteristic.On).on('set', function (value, callback) { 64 | 65 | // Checks if the operation is true, as the reboot cannot be stopped 66 | if (!value) { 67 | return callback(null); 68 | } 69 | 70 | // Executes the action 71 | platform.log(platform.config.bridgeIpAddress + ' - Reboot'); 72 | platform.client.send('/reboot', function () { }); 73 | setTimeout(function () { switchService.setCharacteristic(Characteristic.On, false); }, 5000); 74 | callback(null); 75 | }); 76 | } 77 | 78 | /** 79 | * Defines the export of the file. 80 | */ 81 | module.exports = NukiBridgeDevice; 82 | -------------------------------------------------------------------------------- /src/nuki-opener-device.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents a physical Nuki Opener device. 4 | * @param platform The NukiPlatform instance. 5 | * @param apiConfig The device information provided by the Nuki Bridge API. 6 | * @param config The device configuration. 7 | */ 8 | function NukiOpenerDevice(platform, apiConfig, config) { 9 | const device = this; 10 | const { UUIDGen, Accessory, Characteristic, Service } = platform; 11 | 12 | // Sets the nuki ID, platform and config 13 | device.nukiId = config.nukiId; 14 | device.platform = platform; 15 | device.config = config; 16 | 17 | // Gets all accessories from the platform that match the Nuki ID 18 | let unusedDeviceAccessories = platform.accessories.filter(function(a) { return a.context.nukiId === config.nukiId; }); 19 | let newDeviceAccessories = []; 20 | let deviceAccessories = []; 21 | 22 | // Gets the lock accessory 23 | let lockAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'LockAccessory'; }); 24 | if (lockAccessory) { 25 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(lockAccessory), 1); 26 | } else { 27 | platform.log('Adding new accessory with Nuki ID ' + config.nukiId + ' and kind LockAccessory.'); 28 | lockAccessory = new Accessory(apiConfig.name || 'Nuki', UUIDGen.generate(config.nukiId + 'LockAccessory')); 29 | lockAccessory.context.nukiId = config.nukiId; 30 | lockAccessory.context.kind = 'LockAccessory'; 31 | newDeviceAccessories.push(lockAccessory); 32 | } 33 | deviceAccessories.push(lockAccessory); 34 | 35 | // Gets the switch accessory 36 | let switchAccessory = null; 37 | if (config.isRingToOpenEnabled || config.isContinuousModeEnabled) { 38 | if (config.isSingleAccessoryModeEnabled) { 39 | switchAccessory = lockAccessory; 40 | } else { 41 | switchAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'SwitchAccessory'; }); 42 | if (switchAccessory) { 43 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(switchAccessory), 1); 44 | } else { 45 | platform.log('Adding new accessory with Nuki ID ' + config.nukiId + ' and kind SwitchAccessory.'); 46 | switchAccessory = new Accessory((apiConfig.name || 'Nuki') + ' Settings', UUIDGen.generate(config.nukiId + 'SwitchAccessory')); 47 | switchAccessory.context.nukiId = config.nukiId; 48 | switchAccessory.context.kind = 'SwitchAccessory'; 49 | newDeviceAccessories.push(switchAccessory); 50 | } 51 | deviceAccessories.push(switchAccessory); 52 | } 53 | } 54 | 55 | // Registers the newly created accessories 56 | platform.api.registerPlatformAccessories(platform.pluginName, platform.platformName, newDeviceAccessories); 57 | 58 | // Removes all unused accessories 59 | for (let i = 0; i < unusedDeviceAccessories.length; i++) { 60 | const unusedDeviceAccessory = unusedDeviceAccessories[i]; 61 | platform.log('Removing unused accessory with Nuki ID ' + config.nukiId + ' and kind ' + unusedDeviceAccessory.context.kind + '.'); 62 | platform.accessories.splice(platform.accessories.indexOf(unusedDeviceAccessory), 1); 63 | } 64 | platform.api.unregisterPlatformAccessories(platform.pluginName, platform.platformName, unusedDeviceAccessories); 65 | 66 | // Updates the accessory information 67 | for (let i = 0; i < deviceAccessories.length; i++) { 68 | const deviceAccessory = deviceAccessories[i]; 69 | let accessoryInformationService = deviceAccessory.getService(Service.AccessoryInformation); 70 | if (!accessoryInformationService) { 71 | accessoryInformationService = deviceAccessory.addService(Service.AccessoryInformation); 72 | } 73 | accessoryInformationService 74 | .setCharacteristic(Characteristic.Manufacturer, 'Nuki') 75 | .setCharacteristic(Characteristic.Model, 'Opener') 76 | .setCharacteristic(Characteristic.SerialNumber, config.nukiId.toString()); 77 | } 78 | 79 | // Updates the lock 80 | let lockService = lockAccessory.getService(Service.LockMechanism); 81 | if (!lockService) { 82 | lockService = lockAccessory.addService(Service.LockMechanism); 83 | } 84 | 85 | // Stores the lock service 86 | device.lockService = lockService; 87 | 88 | // Updates the doorbell 89 | let doorbellService = lockAccessory.getService(Service.Doorbell); 90 | if (config.isDoorbellEnabled) { 91 | if (!doorbellService) { 92 | doorbellService = lockAccessory.addService(Service.Doorbell); 93 | } 94 | 95 | // Stores the doorbell service 96 | device.doorbellService = doorbellService; 97 | } else { 98 | if (doorbellService) { 99 | lockAccessory.removeService(doorbellService); 100 | } 101 | } 102 | 103 | // Updates the RTO switch 104 | let ringToOpenSwitchService = null; 105 | if (switchAccessory && config.isRingToOpenEnabled) { 106 | ringToOpenSwitchService = switchAccessory.getServiceByUUIDAndSubType(Service.Switch, 'RingToOpen'); 107 | if (!ringToOpenSwitchService) { 108 | ringToOpenSwitchService = switchAccessory.addService(Service.Switch, 'Ring to Open', 'RingToOpen'); 109 | } 110 | 111 | // Stores the service 112 | device.ringToOpenSwitchService = ringToOpenSwitchService; 113 | } 114 | 115 | // Updates the continuous mode 116 | let continuousModeSwitchService = null; 117 | if (switchAccessory && config.isContinuousModeEnabled) { 118 | continuousModeSwitchService = switchAccessory.getServiceByUUIDAndSubType(Service.Switch, 'ContinuousMode'); 119 | if (!continuousModeSwitchService) { 120 | continuousModeSwitchService = switchAccessory.addService(Service.Switch, 'Continuous Mode', 'ContinuousMode'); 121 | } 122 | 123 | // Stores the service 124 | device.continuousModeSwitchService = continuousModeSwitchService; 125 | } 126 | 127 | // Subscribes for changes of the target state characteristic 128 | lockService.getCharacteristic(Characteristic.LockTargetState).on('set', function (value, callback) { 129 | 130 | // Checks if the operation is unsecured, as the Opener cannot be secured 131 | if (value !== Characteristic.LockTargetState.UNSECURED) { 132 | setTimeout(function () { 133 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 134 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 135 | }, 500); 136 | return callback(null); 137 | } 138 | 139 | // Executes the action 140 | platform.log(config.nukiId + ' - Unlock'); 141 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=2&action=3', function (actionSuccess, actionBody) { 142 | if (actionSuccess && actionBody.success) { 143 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 144 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 145 | } 146 | }); 147 | callback(null); 148 | }); 149 | 150 | // Subscribes for changes of the RTO mode 151 | if (ringToOpenSwitchService) { 152 | ringToOpenSwitchService.getCharacteristic(Characteristic.On).on('set', function (value, callback) { 153 | 154 | // Executes the action 155 | platform.log(config.nukiId + ' - Set RTO to ' + value); 156 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=2&action=' + (value ? '1' : '2'), function () { }); 157 | callback(null); 158 | }); 159 | } 160 | 161 | // Subscribes for changes of the continuous mode 162 | if (continuousModeSwitchService) { 163 | continuousModeSwitchService.getCharacteristic(Characteristic.On).on('set', function (value, callback) { 164 | 165 | // Executes the action 166 | platform.log(config.nukiId + ' - Set Continuous Mode to ' + value); 167 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=2&action=' + (value ? '4' : '5'), function () { }); 168 | callback(null); 169 | }); 170 | } 171 | 172 | // Updates the state initially 173 | device.update(apiConfig.lastKnownState); 174 | } 175 | 176 | /** 177 | * Can be called to update the device information based on the new state. 178 | * @param state The lock state from the API. 179 | */ 180 | NukiOpenerDevice.prototype.update = function (state) { 181 | const device = this; 182 | const { Characteristic } = device.platform; 183 | 184 | // Checks if the state exists, which is not the case if the device is unavailable 185 | if (!state) { 186 | return; 187 | } 188 | 189 | // Sets the lock state 190 | if (state.state == 1 || state.state == 3) { 191 | if (!device.config.leaveOpen) { 192 | device.platform.log(device.nukiId + ' - Updating lock state: SECURED/SECURED'); 193 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 194 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 195 | } 196 | } 197 | if (state.state == 5) { 198 | device.platform.log(device.nukiId + ' - Updating lock state: UNSECURED/UNSECURED'); 199 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 200 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 201 | } 202 | if (state.state == 7) { 203 | device.platform.log(device.nukiId + ' - Updating lock state: -/UNSECURED'); 204 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 205 | } 206 | 207 | // Sets the ring action state 208 | if (device.doorbellService && state.ringactionState) { 209 | if (device.config.suppressDoorbellInContinuousMode && state.mode == 3) { 210 | device.platform.log(device.nukiId + ' - Updating doorbell: Ring suppressed in Continuous Mode'); 211 | } else if (device.config.suppressDoorbellOnRingToOpen && (state.state == 3 || device.ringToOpenSwitchService && device.ringToOpenSwitchService.getCharacteristic(Characteristic.On).value)) { 212 | // If the Opener is configured to reset RTO on ringing, it will reset the state to 1 before performing the callback (in this case the current RTO switch value must be retrieved) 213 | device.platform.log(device.nukiId + ' - Updating doorbell: Ring suppressed on RTO'); 214 | } else { 215 | device.platform.log(device.nukiId + ' - Updating doorbell: Ring'); 216 | device.doorbellService.updateCharacteristic(Characteristic.ProgrammableSwitchEvent, 0); 217 | } 218 | } 219 | 220 | // Sets the status for the continuous mode 221 | if (device.continuousModeSwitchService) { 222 | device.platform.log(device.nukiId + ' - Updating Continuous Mode: ' + state.mode); 223 | device.continuousModeSwitchService.updateCharacteristic(Characteristic.On, state.mode == 3); 224 | } 225 | 226 | // Sets the status for RTO 227 | if (device.ringToOpenSwitchService) { 228 | if (state.state == 1 || state.state == 3) { 229 | device.platform.log(device.nukiId + ' - Updating RTO: ' + state.state); 230 | device.ringToOpenSwitchService.updateCharacteristic(Characteristic.On, state.state == 3); 231 | } 232 | } 233 | 234 | // Sets the status of the battery 235 | device.platform.log(device.nukiId + ' - Updating critical battery: ' + state.batteryCritical); 236 | if (state.batteryCritical) { 237 | device.lockService.updateCharacteristic(Characteristic.StatusLowBattery, Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); 238 | } else { 239 | device.lockService.updateCharacteristic(Characteristic.StatusLowBattery, Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL); 240 | } 241 | } 242 | 243 | /** 244 | * Defines the export of the file. 245 | */ 246 | module.exports = NukiOpenerDevice; 247 | -------------------------------------------------------------------------------- /src/nuki-platform.js: -------------------------------------------------------------------------------- 1 | 2 | const http = require('http'); 3 | 4 | const NukiBridgeClient = require('./nuki-bridge-client'); 5 | const NukiSmartLockDevice = require('./nuki-smart-lock-device'); 6 | const NukiOpenerDevice = require('./nuki-opener-device'); 7 | const NukiBridgeDevice = require('./nuki-bridge-device'); 8 | const NukiApi = require('./nuki-api'); 9 | 10 | /** 11 | * Initializes a new platform instance for the Nuki plugin. 12 | * @param log The logging function. 13 | * @param config The configuration that is passed to the plugin (from the config.json file). 14 | * @param api The API instance of homebridge (may be null on older homebridge versions). 15 | */ 16 | function NukiPlatform(log, config, api) { 17 | const platform = this; 18 | 19 | // Saves objects for functions 20 | platform.Accessory = api.platformAccessory; 21 | platform.Categories = api.hap.Accessory.Categories; 22 | platform.Service = api.hap.Service; 23 | platform.Characteristic = api.hap.Characteristic; 24 | platform.UUIDGen = api.hap.uuid; 25 | platform.hap = api.hap; 26 | platform.pluginName = 'homebridge-nuki'; 27 | platform.platformName = 'NukiPlatform'; 28 | 29 | // Checks whether a configuration is provided, otherwise the plugin should not be initialized 30 | if (!config) { 31 | return; 32 | } 33 | 34 | // Defines the variables that are used throughout the platform 35 | platform.log = log; 36 | platform.config = config; 37 | platform.devices = []; 38 | platform.accessories = []; 39 | 40 | // Initializes the configuration 41 | platform.config.hostNameOrIpAddress = platform.config.hostNameOrIpAddress || null; 42 | platform.config.hostCallbackApiPort = platform.config.hostCallbackApiPort || 40506; 43 | platform.config.bridgeIpAddress = platform.config.bridgeIpAddress || null; 44 | platform.config.bridgeApiPort = platform.config.bridgeApiPort || 8080; 45 | platform.config.bridgeApiToken = platform.config.bridgeApiToken || null; 46 | platform.config.devices = platform.config.devices || []; 47 | platform.config.isApiEnabled = platform.config.isApiEnabled || false; 48 | platform.config.apiPort = platform.config.apiPort || 40011; 49 | platform.config.apiToken = platform.config.apiToken || null; 50 | platform.config.supportedDeviceTypes = [0, 2]; 51 | platform.config.requestBuffer = 3000; 52 | platform.config.requestRetryCount = 3; 53 | 54 | // Initializes the client 55 | platform.client = new NukiBridgeClient(platform); 56 | 57 | // Checks whether the API object is available 58 | if (!api) { 59 | platform.log('Homebridge API not available, please update your homebridge version!'); 60 | return; 61 | } 62 | 63 | // Saves the API object to register new devices later on 64 | platform.log('Homebridge API available.'); 65 | platform.api = api; 66 | 67 | // Subscribes to the event that is raised when homebridge finished loading cached accessories 68 | platform.api.on('didFinishLaunching', function () { 69 | platform.log('Cached accessories loaded.'); 70 | 71 | // Initially gets the devices from the Nuki Bridge API 72 | platform.getDevicesFromApi(function (devicesResult) { 73 | if (devicesResult) { 74 | platform.startCallbackServer(function (callbackServerResult) { 75 | if (callbackServerResult) { 76 | platform.registerCallback(function () { }); 77 | } 78 | }); 79 | 80 | // Starts the API if requested 81 | if (platform.config.isApiEnabled) { 82 | platform.nukiApi = new NukiApi(platform); 83 | } 84 | } 85 | }); 86 | }); 87 | } 88 | 89 | /** 90 | * Gets the devices from the Bridge API. 91 | * @param callback The callback function that gets a boolean value indicating success or failure. 92 | */ 93 | NukiPlatform.prototype.getDevicesFromApi = function (callback) { 94 | const platform = this; 95 | 96 | // Sends a request to the API to get all devices 97 | platform.client.send('/list', function (success, body) { 98 | 99 | // Checks the result 100 | if (!success) { 101 | return callback(false); 102 | } 103 | 104 | // Stores the devices in the plugin 105 | platform.apiConfig = body; 106 | 107 | // Initializes a device for each device from the API 108 | for (let i = 0; i < body.length; i++) { 109 | const apiConfig = body[i]; 110 | 111 | // Checks if the device is supported by this plugin 112 | if (!platform.config.supportedDeviceTypes.some(function(t) { return t === apiConfig.deviceType; })) { 113 | platform.log('Device with Nuki ID ' + apiConfig.nukiId + ' not added, as it is not supported by this plugin.'); 114 | continue; 115 | } 116 | 117 | // Prints out the device information 118 | if (apiConfig.deviceType == 0) { 119 | platform.log('Device with Nuki ID ' + apiConfig.nukiId + ' and name ' + apiConfig.name + ' is a SmartLock.'); 120 | } 121 | if (apiConfig.deviceType == 2) { 122 | platform.log('Device with Nuki ID ' + apiConfig.nukiId + ' and name ' + apiConfig.name + ' is an Opener.'); 123 | } 124 | 125 | // Gets the corresponding device configuration 126 | const config = platform.config.devices.find(function(d) { return d.nukiId === apiConfig.nukiId; }); 127 | if (!config) { 128 | platform.log('No configuration provided for device with Nuki ID ' + apiConfig.nukiId + '.'); 129 | continue; 130 | } 131 | 132 | // Creates the device instance and adds it to the list of all devices 133 | if (apiConfig.deviceType == 0) { 134 | platform.devices.push(new NukiSmartLockDevice(platform, apiConfig, config)); 135 | } 136 | if (apiConfig.deviceType == 2) { 137 | platform.devices.push(new NukiOpenerDevice(platform, apiConfig, config)); 138 | } 139 | } 140 | 141 | // Checks if the Bridge should be added as device 142 | if (platform.config.bridgeRebootSwitch) { 143 | platform.devices.push(new NukiBridgeDevice(platform)); 144 | } 145 | 146 | // Removes the accessories that are not bound to a device 147 | let unusedAccessories = platform.accessories.filter(function(a) { return !platform.devices.some(function(d) { return (!!d.nukiId && d.nukiId === a.context.nukiId) || (!!d.bridgeIpAddress && d.bridgeIpAddress === a.context.bridgeIpAddress); }); }); 148 | for (let i = 0; i < unusedAccessories.length; i++) { 149 | const unusedAccessory = unusedAccessories[i]; 150 | platform.log('Removing accessory with Nuki ID ' + unusedAccessory.context.nukiId + ' and kind ' + unusedAccessory.context.kind + '.'); 151 | platform.accessories.splice(platform.accessories.indexOf(unusedAccessory), 1); 152 | } 153 | platform.api.unregisterPlatformAccessories(platform.pluginName, platform.platformName, unusedAccessories); 154 | 155 | // Returns a positive result 156 | platform.log('Got devices from the Nuki Bridge API.'); 157 | return callback(true); 158 | }); 159 | } 160 | 161 | /** 162 | * Starts the server that receives the callbacks from the Bridge API. 163 | * @param callback The callback function that gets a boolean value indicating success or failure. 164 | */ 165 | NukiPlatform.prototype.startCallbackServer = function (callback) { 166 | const platform = this; 167 | 168 | // Checks if all required information is provided 169 | if (!platform.config.hostNameOrIpAddress) { 170 | platform.log('No host name or IP address provided.'); 171 | return callback(false); 172 | } 173 | if (!platform.config.hostCallbackApiPort) { 174 | platform.log('No API port for callback (host) provided.'); 175 | return callback(false); 176 | } 177 | 178 | // Starts the server 179 | try { 180 | http.createServer(function (request, response) { 181 | const payload = []; 182 | 183 | // Subscribes for events of the request 184 | request.on('error', function () { 185 | platform.log('Error received from callback server.'); 186 | }).on('data', function (chunk) { 187 | payload.push(chunk); 188 | }).on('end', function () { 189 | 190 | // Subscribes to errors when sending the response 191 | response.on('error', function () { 192 | platform.log('Error sending the response from callback server.'); 193 | }); 194 | 195 | // Generates the request string 196 | const content = JSON.parse(Buffer.concat(payload).toString()); 197 | 198 | // Checks if the request is valid 199 | if (!content.nukiId) { 200 | platform.log('Callback received, but invalid.'); 201 | response.statusCode = 400; 202 | response.end(); 203 | } 204 | 205 | // Sends a response to the Bridge API 206 | platform.log('Callback received.'); 207 | response.statusCode = 200; 208 | response.end(); 209 | 210 | // Updates the device 211 | for (let i = 0; i < platform.devices.length; i++) { 212 | if (platform.devices[i].nukiId == content.nukiId) { 213 | platform.devices[i].update(content); 214 | } 215 | } 216 | 217 | // Updates the API config object 218 | for (let i = 0; i < platform.apiConfig.length; i++) { 219 | if (platform.apiConfig[i].nukiId == content.nukiId) { 220 | platform.apiConfig[i].lastKnownState = content; 221 | } 222 | } 223 | }); 224 | }).listen(platform.config.hostCallbackApiPort, "0.0.0.0"); 225 | 226 | // Returns a positive result 227 | platform.log('Callback server started.'); 228 | return callback(true); 229 | } catch (e) { 230 | 231 | // Returns a negative result 232 | platform.log('Callback server could not be started: ' + JSON.stringify(e)); 233 | return callback(false); 234 | } 235 | } 236 | 237 | /** 238 | * Registers the callback for changes of the lock states. 239 | * @param callback The callback function that gets a boolean value indicating success or failure. 240 | */ 241 | NukiPlatform.prototype.registerCallback = function (callback) { 242 | const platform = this; 243 | 244 | // Checks if all required information is provided 245 | if (!platform.config.hostNameOrIpAddress) { 246 | platform.log('No host name or IP address provided.'); 247 | return callback(false); 248 | } 249 | if (!platform.config.hostCallbackApiPort) { 250 | platform.log('No API port for callback (host) provided.'); 251 | return callback(false); 252 | } 253 | 254 | // Sends a request to the API to get all callback URIs 255 | platform.client.send('/callback/list', function (success, body) { 256 | 257 | // Checks the result 258 | if (!success) { 259 | return callback(false); 260 | } 261 | 262 | // Checks if the callback is already registered 263 | if (body.callbacks && body.callbacks.some(function(c) { return c.url === 'http://' + platform.config.hostNameOrIpAddress + ':' + platform.config.hostCallbackApiPort; })) { 264 | platform.log('Callback already registered.'); 265 | return callback(true); 266 | } 267 | 268 | // Adds the callback to the Bridge API 269 | platform.client.send('/callback/add?url=' + encodeURI('http://' + platform.config.hostNameOrIpAddress + ':' + platform.config.hostCallbackApiPort), function (innerSuccess) { 270 | 271 | // Checks the result 272 | if (!innerSuccess) { 273 | return callback(false); 274 | } 275 | 276 | // Returns a positive result 277 | platform.log('Callback registered.'); 278 | return callback(true); 279 | }); 280 | }); 281 | } 282 | 283 | /** 284 | * Configures a previously cached accessory. 285 | * @param accessory The cached accessory. 286 | */ 287 | NukiPlatform.prototype.configureAccessory = function (accessory) { 288 | const platform = this; 289 | 290 | // Adds the cached accessory to the list 291 | platform.accessories.push(accessory); 292 | } 293 | 294 | /** 295 | * Defines the export of the file. 296 | */ 297 | module.exports = NukiPlatform; 298 | -------------------------------------------------------------------------------- /src/nuki-smart-lock-device.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents a physical Nuki SmartLock device. 4 | * @param platform The NukiPlatform instance. 5 | * @param apiConfig The device information provided by the Nuki Bridge API. 6 | * @param config The device configuration. 7 | */ 8 | function NukiSmartLockDevice(platform, apiConfig, config) { 9 | const device = this; 10 | const { UUIDGen, Accessory, Characteristic, Service } = platform; 11 | 12 | // Sets the nuki ID and platform 13 | device.nukiId = config.nukiId; 14 | device.platform = platform; 15 | device.lockName = config.defaultLockName || 'Lock'; 16 | device.latchName = config.defaultLatchName || 'Latch'; 17 | 18 | // Gets all accessories from the platform that match the Nuki ID 19 | let unusedDeviceAccessories = platform.accessories.filter(function(a) { return a.context.nukiId === config.nukiId; }); 20 | let newDeviceAccessories = []; 21 | let deviceAccessories = []; 22 | 23 | // Gets the lock accessory 24 | let lockAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'LockAccessory'; }); 25 | if (lockAccessory) { 26 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(lockAccessory), 1); 27 | } else { 28 | platform.log('Adding new accessory with Nuki ID ' + config.nukiId + ' and kind LockAccessory.'); 29 | lockAccessory = new Accessory(apiConfig.name || 'Nuki', UUIDGen.generate(config.nukiId + 'LockAccessory')); 30 | lockAccessory.context.nukiId = config.nukiId; 31 | lockAccessory.context.kind = 'LockAccessory'; 32 | newDeviceAccessories.push(lockAccessory); 33 | } 34 | deviceAccessories.push(lockAccessory); 35 | 36 | // Gets the contact sensor accessory 37 | let contactSensorAccessory = null; 38 | if (config.isDoorSensorEnabled) { 39 | if (config.isSingleAccessoryModeEnabled) { 40 | contactSensorAccessory = lockAccessory; 41 | } else { 42 | contactSensorAccessory = unusedDeviceAccessories.find(function(a) { return a.context.kind === 'ContactSensorAccessory'; }); 43 | if (contactSensorAccessory) { 44 | unusedDeviceAccessories.splice(unusedDeviceAccessories.indexOf(contactSensorAccessory), 1); 45 | } else { 46 | platform.log('Adding new accessory with Nuki ID ' + config.nukiId + ' and kind ContactSensorAccessory.'); 47 | contactSensorAccessory = new Accessory((apiConfig.name || 'Nuki') + ' Settings', UUIDGen.generate(config.nukiId + 'ContactSensorAccessory')); 48 | contactSensorAccessory.context.nukiId = config.nukiId; 49 | contactSensorAccessory.context.kind = 'ContactSensorAccessory'; 50 | newDeviceAccessories.push(contactSensorAccessory); 51 | } 52 | deviceAccessories.push(contactSensorAccessory); 53 | } 54 | } 55 | 56 | // Registers the newly created accessories 57 | platform.api.registerPlatformAccessories(platform.pluginName, platform.platformName, newDeviceAccessories); 58 | 59 | // Removes all unused accessories 60 | for (let i = 0; i < unusedDeviceAccessories.length; i++) { 61 | const unusedDeviceAccessory = unusedDeviceAccessories[i]; 62 | platform.log('Removing unused accessory with Nuki ID ' + config.nukiId + ' and kind ' + unusedDeviceAccessory.context.kind + '.'); 63 | platform.accessories.splice(platform.accessories.indexOf(unusedDeviceAccessory), 1); 64 | } 65 | platform.api.unregisterPlatformAccessories(platform.pluginName, platform.platformName, unusedDeviceAccessories); 66 | 67 | // Updates the accessory information 68 | for (let i = 0; i < deviceAccessories.length; i++) { 69 | const deviceAccessory = deviceAccessories[i]; 70 | let accessoryInformationService = deviceAccessory.getService(Service.AccessoryInformation); 71 | if (!accessoryInformationService) { 72 | accessoryInformationService = deviceAccessory.addService(Service.AccessoryInformation); 73 | } 74 | accessoryInformationService 75 | .setCharacteristic(Characteristic.Manufacturer, 'Nuki') 76 | .setCharacteristic(Characteristic.Model, 'SmartLock') 77 | .setCharacteristic(Characteristic.SerialNumber, config.nukiId.toString()); 78 | } 79 | 80 | // Updates the lock 81 | let lockService = lockAccessory.getServiceByUUIDAndSubType(Service.LockMechanism, 'Lock'); 82 | if (!lockService) { 83 | lockService = lockAccessory.addService(Service.LockMechanism, device.lockName, 'Lock'); 84 | } 85 | 86 | // Stores the lock service 87 | device.lockService = lockService; 88 | 89 | // Updates the unlatch service 90 | let unlatchService = lockAccessory.getServiceByUUIDAndSubType(Service.LockMechanism, 'Unlatch'); 91 | if (config.unlatchLock) { 92 | if (!unlatchService) { 93 | unlatchService = lockAccessory.addService(Service.LockMechanism, device.latchName, 'Unlatch'); 94 | } 95 | 96 | // Stores the service 97 | device.unlatchService = unlatchService; 98 | } else { 99 | if (unlatchService) { 100 | lockAccessory.removeService(unlatchService); 101 | unlatchService = null; 102 | } 103 | } 104 | 105 | // Removes the old low battery characteristic 106 | let statusLowBatteryCharacteristic = lockService.getCharacteristic(Characteristic.StatusLowBattery); 107 | if (statusLowBatteryCharacteristic) { 108 | lockService.removeCharacteristic(statusLowBatteryCharacteristic); 109 | } 110 | 111 | // Updates the battery service 112 | let batteryService = lockAccessory.getServiceByUUIDAndSubType(Service.BatteryService, 'Battery'); 113 | if (!batteryService) { 114 | batteryService = lockAccessory.addService(Service.BatteryService, 'Battery', 'Battery'); 115 | } 116 | 117 | // Stores the battery service 118 | device.batteryService = batteryService; 119 | 120 | // Updates the contact sensor 121 | let contactSensorService = null; 122 | if (contactSensorAccessory && config.isDoorSensorEnabled) { 123 | contactSensorService = contactSensorAccessory.getServiceByUUIDAndSubType(Service.ContactSensor, 'DoorSensor'); 124 | if (!contactSensorService) { 125 | contactSensorService = contactSensorAccessory.addService(Service.ContactSensor, 'Door', 'DoorSensor'); 126 | } 127 | 128 | // Stores the service 129 | device.contactSensorService = contactSensorService; 130 | } 131 | 132 | // Subscribes for changes of the target state characteristic 133 | lockService.getCharacteristic(Characteristic.LockTargetState).on('set', function (value, callback) { 134 | 135 | // Checks if the operation is unsecured 136 | if (value === Characteristic.LockTargetState.UNSECURED) { 137 | if (lockService.getCharacteristic(Characteristic.LockCurrentState).value === Characteristic.LockCurrentState.SECURED) { 138 | if (config.unlatchFromLockedToUnlocked) { 139 | 140 | // Sets the target state of the unlatch switch to unsecured, as both should be displayed as open 141 | if (unlatchService) { 142 | unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 143 | } 144 | 145 | // Unlatches the door 146 | platform.log(config.nukiId + ' - Unlatch'); 147 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=0&action=3', function (actionSuccess, actionBody) { 148 | if (actionSuccess && actionBody.success) { 149 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 150 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 151 | } 152 | }); 153 | 154 | } else { 155 | 156 | // Unlocks the door 157 | platform.log(config.nukiId + ' - Unlock'); 158 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=0&action=1', function (actionSuccess, actionBody) { 159 | if (actionSuccess && actionBody.success) { 160 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 161 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 162 | } 163 | }); 164 | } 165 | } 166 | if (lockService.getCharacteristic(Characteristic.LockCurrentState).value === Characteristic.LockCurrentState.UNSECURED) { 167 | if (config.unlatchFromUnlockedToUnlocked) { 168 | 169 | // Sets the target state of the unlatch switch to unsecured, as both should be displayed as open 170 | if (unlatchService) { 171 | unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 172 | } 173 | 174 | // Unlatches the door 175 | platform.log(config.nukiId + ' - Unlatch'); 176 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=0&action=3', function (actionSuccess, actionBody) { 177 | if (actionSuccess && actionBody.success) { 178 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 179 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 180 | } 181 | }); 182 | 183 | } 184 | } 185 | } 186 | 187 | // Checks if the operation is secured 188 | if (value === Characteristic.LockTargetState.SECURED) { 189 | if (lockService.getCharacteristic(Characteristic.LockCurrentState).value === Characteristic.LockCurrentState.SECURED) { 190 | if (config.lockFromLockedToLocked) { 191 | platform.log(config.nukiId + ' - Lock again (already locked)'); 192 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=0&action=2', function (actionSuccess, actionBody) { 193 | if (actionSuccess && actionBody.success) { 194 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 195 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 196 | } 197 | }); 198 | } 199 | } else { 200 | platform.log(config.nukiId + ' - Lock'); 201 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=0&action=2', function (actionSuccess, actionBody) { 202 | if (actionSuccess && actionBody.success) { 203 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 204 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 205 | } 206 | }); 207 | } 208 | } 209 | 210 | // Performs the callback 211 | callback(null); 212 | }); 213 | 214 | // Subscribes for changes of the unlatch lock 215 | if (unlatchService) { 216 | unlatchService.getCharacteristic(Characteristic.LockTargetState).on('set', function (value, callback) { 217 | 218 | // Checks if the operation is unsecured, as the latch cannot be secured 219 | if (value !== Characteristic.LockTargetState.UNSECURED) { 220 | return callback(null); 221 | } 222 | 223 | // Checks if the safety mechanism is enabled, so that the lock cannot unlatch when locked 224 | if (lockService.getCharacteristic(Characteristic.LockCurrentState).value === Characteristic.LockCurrentState.SECURED) { 225 | if (config.unlatchLockPreventUnlatchIfLocked) { 226 | unlatchService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 227 | unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 228 | return; 229 | } 230 | } 231 | 232 | // Sets the target state of the lock to unsecured, as both should be displayed as open 233 | lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 234 | 235 | // Unlatches the lock 236 | platform.log(config.nukiId + ' - Unlatch'); 237 | platform.client.send('/lockAction?nukiId=' + config.nukiId + '&deviceType=0&action=3', function (actionSuccess, actionBody) { 238 | if (actionSuccess && actionBody.success) { 239 | unlatchService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 240 | unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 241 | } 242 | }); 243 | callback(null); 244 | }); 245 | } 246 | 247 | // Updates the state initially 248 | device.update(apiConfig.lastKnownState); 249 | } 250 | 251 | /** 252 | * Can be called to update the device information based on the new state. 253 | * @param state The lock state from the API. 254 | */ 255 | NukiSmartLockDevice.prototype.update = function (state) { 256 | const device = this; 257 | const { Characteristic } = device.platform; 258 | 259 | // Checks if the state exists, which is not the case if the device is unavailable 260 | if (!state) { 261 | return; 262 | } 263 | 264 | // Sets the lock state 265 | if (state.state == 1) { 266 | device.platform.log(device.nukiId + ' - Updating lock state: SECURED/SECURED'); 267 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 268 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 269 | if (device.unlatchService) { 270 | device.unlatchService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 271 | device.unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 272 | } 273 | } 274 | if (state.state == 3) { 275 | device.platform.log(device.nukiId + ' - Updating lock state: UNSECURED/UNSECURED'); 276 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 277 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 278 | if (device.unlatchService) { 279 | device.platform.log(device.nukiId + ' - Updating latch state: SECURED/SECURED'); 280 | device.unlatchService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.SECURED); 281 | device.unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.SECURED); 282 | } 283 | } 284 | if (state.state == 5) { 285 | device.platform.log(device.nukiId + ' - Updating lock state: UNSECURED/UNSECURED'); 286 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 287 | device.lockService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 288 | if (device.unlatchService) { 289 | device.platform.log(device.nukiId + ' - Updating latch state: UNSECURED/UNSECURED'); 290 | device.unlatchService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.UNSECURED); 291 | device.unlatchService.updateCharacteristic(Characteristic.LockTargetState, Characteristic.LockTargetState.UNSECURED); 292 | } 293 | } 294 | if (state.state == 254) { 295 | device.platform.log(device.nukiId + ' - Updating lock state: JAMMED/-'); 296 | device.lockService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.JAMMED); 297 | if (device.unlatchService) { 298 | device.unlatchService.updateCharacteristic(Characteristic.LockCurrentState, Characteristic.LockCurrentState.JAMMED); 299 | } 300 | } 301 | 302 | // Sets the status of the battery 303 | device.platform.log(device.nukiId + ' - Updating critical battery: ' + state.batteryCritical); 304 | if (state.batteryCritical) { 305 | device.batteryService.updateCharacteristic(Characteristic.StatusLowBattery, Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW); 306 | } else { 307 | device.batteryService.updateCharacteristic(Characteristic.StatusLowBattery, Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL); 308 | } 309 | if (state.batteryCharging === false) { 310 | device.platform.log(device.nukiId + ' - Updating battery charging: NO'); 311 | device.batteryService.updateCharacteristic(Characteristic.ChargingState, Characteristic.ChargingState.NOT_CHARGING); 312 | } 313 | if (state.batteryCharging === true) { 314 | device.platform.log(device.nukiId + ' - Updating battery charging: YES'); 315 | device.batteryService.updateCharacteristic(Characteristic.ChargingState, Characteristic.ChargingState.CHARGING); 316 | } 317 | if (state.batteryChargeState && !isNaN(state.batteryChargeState)) { 318 | device.platform.log(device.nukiId + ' - Updating battery level: ' + state.batteryChargeState); 319 | device.batteryService.updateCharacteristic(Characteristic.BatteryLevel, state.batteryChargeState); 320 | } 321 | 322 | // Sets the door sensor state 323 | if (device.contactSensorService) { 324 | if (state.doorsensorState === 3) { 325 | device.platform.log(device.nukiId + ' - Updating Door State: Open'); 326 | device.contactSensorService.updateCharacteristic(Characteristic.ContactSensorState, Characteristic.ContactSensorState.CONTACT_NOT_DETECTED); 327 | device.contactSensorService.updateCharacteristic(Characteristic.StatusFault, Characteristic.StatusFault.NO_FAULT); 328 | } else if (state.doorsensorState === 2) { 329 | device.platform.log(device.nukiId + ' - Updating Door State: Closed'); 330 | device.contactSensorService.updateCharacteristic(Characteristic.ContactSensorState, Characteristic.ContactSensorState.CONTACT_DETECTED); 331 | device.contactSensorService.updateCharacteristic(Characteristic.StatusFault, Characteristic.StatusFault.NO_FAULT); 332 | } else { 333 | device.platform.log(device.nukiId + ' - Updating Door State: Fault'); 334 | device.contactSensorService.updateCharacteristic(Characteristic.StatusFault, Characteristic.StatusFault.GENERAL_FAULT); 335 | } 336 | } 337 | } 338 | 339 | /** 340 | * Defines the export of the file. 341 | */ 342 | module.exports = NukiSmartLockDevice; 343 | --------------------------------------------------------------------------------