├── .gitignore ├── README.md ├── blescan.py ├── byteswap.py ├── crc.py ├── gatttool.py ├── nuki.py └── nuki_messages.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nukiPyBridge 2 | 3 | This python library let's you talk with Nuki lock (https://nuki.io/en/) 4 | 5 | ## Get started 6 | 1. install a BLE-compatible USB dongle (or use the built-in bluetooth stack if available) 7 | 2. sudo apt-get install libffi-dev libbluetooth-dev 8 | 3. install bluez (https://learn.adafruit.com/install-bluez-on-the-raspberry-pi/installation) 9 | 4. install pygatt (pip install pygatt) 10 | 5. replace the /usr/local/lib/python2.7/dist-packages/pygatt/backends/gatttool/gatttool.py file with the file from this repository. 11 | 6. install nacl (pip install pynacl) 12 | 7. install crc16 (pip install crc16) 13 | 8. install pybluez (pip install pybluez) 14 | 9. install pexpect (pip install pexpect) 15 | 10. ready to start using the library in python! 16 | 17 | ## Example usage 18 | ### Authenticate 19 | Before you will be able to send commands to the Nuki lock using the library, you must first authenticate (once!) yourself with a self-generated public/private keypair (using NaCl): 20 | ```python 21 | import nuki_messages 22 | import nuki 23 | from nacl.public import PrivateKey 24 | 25 | nukiMacAddress = "00:00:00:00:00:01" 26 | # generate the private key which must be kept secret 27 | keypair = PrivateKey.generate() 28 | myPublicKeyHex = keypair.public_key.__bytes__().encode("hex") 29 | myPrivateKeyHex = keypair.__bytes__().encode("hex") 30 | myID = 50 31 | # id-type = 00 (app), 01 (bridge) or 02 (fob) 32 | # take 01 (bridge) if you want to make sure that the 'new state available'-flag is cleared on the Nuki if you read it out the state using this library 33 | myIDType = '01' 34 | myName = "PiBridge" 35 | 36 | nuki = nuki.Nuki(nukiMacAddress) 37 | nuki.authenticateUser(myPublicKeyHex, myPrivateKeyHex, myID, myIDType, myName) 38 | ``` 39 | 40 | **REMARK 1** The credentials are stored in the file (hard-coded for the moment in nuki.py) : /home/pi/nuki/nuki.cfg 41 | 42 | **REMARK 2** Authenticating is only possible if the lock is in 'pairing mode'. You can set it to this mode by pressing the button on the lock for 5 seconds until the complete LED ring starts to shine. 43 | 44 | **REMARK 3** You can find out your Nuki's MAC address by using 'hcitool lescan' for example. 45 | 46 | **REMARK 4** The device needs to be initialized once (i.e. using the Nuki app on your cell phone) before it can be controlled with this library. 47 | 48 | ### Commands for Nuki 49 | Once you are authenticated (and the nuki.cfg file is created on your system), you can use the library to send command to your Nuki lock: 50 | ```python 51 | import nuki_messages 52 | import nuki 53 | 54 | nukiMacAddress = "00:00:00:00:00:01" 55 | Pin = "%04x" % 1234 56 | 57 | nuki = nuki.Nuki(nukiMacAddress) 58 | nuki.readLockState() 59 | nuki.lockAction("UNLOCK") 60 | logs = nuki.getLogEntries(10,Pin) 61 | print "received %d log entries" % len(logs) 62 | 63 | available = nuki.isNewNukiStateAvailable() 64 | print "New state available: %d" % available 65 | 66 | ``` 67 | **REMARK** the method ```isNewNukiStateAvailable()``` only works if you run your python script as root (sudo) or if you allow some capabilites. See below. All the other methods do not require root privileges 68 | 69 | ### isNewNukiStateAvailable() without root 70 | If you want to run isNewNukiStateAvailable() without root, allow python to use the cap_net_raw+eip capability: 71 | ```bash 72 | sudo setcap cap_net_raw+eip $(eval readlink -f `which python`) 73 | ``` 74 | -------------------------------------------------------------------------------- /blescan.py: -------------------------------------------------------------------------------- 1 | # BLE iBeaconScanner based on https://github.com/adamf/BLE/blob/master/ble-scanner.py 2 | # JCS 06/07/14 3 | 4 | DEBUG = False 5 | # BLE scanner based on https://github.com/adamf/BLE/blob/master/ble-scanner.py 6 | # BLE scanner, based on https://code.google.com/p/pybluez/source/browse/trunk/examples/advanced/inquiry-with-rssi.py 7 | 8 | # https://github.com/pauloborges/bluez/blob/master/tools/hcitool.c for lescan 9 | # https://kernel.googlesource.com/pub/scm/bluetooth/bluez/+/5.6/lib/hci.h for opcodes 10 | # https://github.com/pauloborges/bluez/blob/master/lib/hci.c#L2782 for functions used by lescan 11 | 12 | # performs a simple device inquiry, and returns a list of ble advertizements 13 | # discovered device 14 | 15 | # NOTE: Python's struct.pack() will add padding bytes unless you make the endianness explicit. Little endian 16 | # should be used for BLE. Always start a struct.pack() format string with "<" 17 | 18 | import os 19 | import sys 20 | import struct 21 | import bluetooth._bluetooth as bluez 22 | 23 | LE_META_EVENT = 0x3e 24 | LE_PUBLIC_ADDRESS=0x00 25 | LE_RANDOM_ADDRESS=0x01 26 | LE_SET_SCAN_PARAMETERS_CP_SIZE=7 27 | OGF_LE_CTL=0x08 28 | OCF_LE_SET_SCAN_PARAMETERS=0x000B 29 | OCF_LE_SET_SCAN_ENABLE=0x000C 30 | OCF_LE_CREATE_CONN=0x000D 31 | 32 | LE_ROLE_MASTER = 0x00 33 | LE_ROLE_SLAVE = 0x01 34 | 35 | # these are actually subevents of LE_META_EVENT 36 | EVT_LE_CONN_COMPLETE=0x01 37 | EVT_LE_ADVERTISING_REPORT=0x02 38 | EVT_LE_CONN_UPDATE_COMPLETE=0x03 39 | EVT_LE_READ_REMOTE_USED_FEATURES_COMPLETE=0x04 40 | 41 | # Advertisment event types 42 | ADV_IND=0x00 43 | ADV_DIRECT_IND=0x01 44 | ADV_SCAN_IND=0x02 45 | ADV_NONCONN_IND=0x03 46 | ADV_SCAN_RSP=0x04 47 | 48 | 49 | def returnnumberpacket(pkt): 50 | myInteger = 0 51 | multiple = 256 52 | for c in pkt: 53 | myInteger += struct.unpack("B",c)[0] * multiple 54 | multiple = 1 55 | return myInteger 56 | 57 | def returnstringpacket(pkt): 58 | myString = ""; 59 | for c in pkt: 60 | myString += "%02x" %struct.unpack("B",c)[0] 61 | return myString 62 | 63 | def printpacket(pkt): 64 | for c in pkt: 65 | sys.stdout.write("%02x " % struct.unpack("B",c)[0]) 66 | 67 | def get_packed_bdaddr(bdaddr_string): 68 | packable_addr = [] 69 | addr = bdaddr_string.split(':') 70 | addr.reverse() 71 | for b in addr: 72 | packable_addr.append(int(b, 16)) 73 | return struct.pack("', 76 | }, 77 | } 78 | 79 | for event in self._event_vector.values(): 80 | event["event"] = threading.Event() 81 | event["before"] = None 82 | event["after"] = None 83 | event["match"] = None 84 | event["callback"] = None 85 | 86 | def run(self): 87 | items = [ 88 | (event["pattern"], event) 89 | for event in self._event_vector.values() 90 | ] 91 | patterns = [item[0] for item in items] 92 | events = [item[1] for item in items] 93 | 94 | log.info('Running...') 95 | while self._parent_aliveness.is_set(): 96 | try: 97 | event_index = self._connection.expect(patterns, timeout=.5) 98 | except pexpect.TIMEOUT: 99 | continue 100 | except (NotConnectedError, pexpect.EOF): 101 | self._event_vector["disconnected"]["event"].set() 102 | break 103 | event = events[event_index] 104 | event["before"] = self._connection.before 105 | event["after"] = self._connection.after 106 | event["match"] = self._connection.match 107 | event["event"].set() 108 | if event["callback"]: 109 | event["callback"](event) 110 | log.info("Listener thread finished") 111 | 112 | def clear(self, event): 113 | """ 114 | Clear event 115 | """ 116 | self._event_vector[event]["event"].clear() 117 | 118 | def is_set(self, event): 119 | return self._event_vector[event]["event"].is_set() 120 | 121 | def wait(self, event, timeout=None): 122 | """ 123 | Wait for event to be trigerred 124 | """ 125 | if not self._event_vector[event]["event"].wait(timeout): 126 | raise NotificationTimeout() 127 | 128 | def register_callback(self, event, callback): 129 | """ 130 | Call the callback function when event happens. Event wrapper 131 | is passed as argument. 132 | """ 133 | self._event_vector[event]["callback"] = callback 134 | 135 | def last_value(self, event, value_type): 136 | """ 137 | Retrieve last value that saved by the event 138 | """ 139 | return self._event_vector[event][value_type] 140 | 141 | @contextmanager 142 | def event(self, event, timeout=None): 143 | """ 144 | Clear an event, execute context and then wait for event 145 | 146 | >>> with gtr.event("connect", 10): 147 | >>> gtb.send(connect_command) 148 | 149 | """ 150 | self.clear(event) 151 | yield 152 | self.wait(event, timeout) 153 | 154 | 155 | class GATTToolBackend(BLEBackend): 156 | """ 157 | Backend to pygatt that uses BlueZ's interactive gatttool CLI prompt. 158 | """ 159 | 160 | def __init__(self, hci_device='hci0', gatttool_logfile=None, 161 | cli_options=None): 162 | """ 163 | Initialize. 164 | 165 | hci_device -- the hci_device to use with GATTTool. 166 | gatttool_logfile -- an optional filename to store raw gatttool 167 | input and output. 168 | """ 169 | self._hci_device = hci_device 170 | self._cli_options = cli_options 171 | self._connected_device = None 172 | self._gatttool_logfile = gatttool_logfile 173 | self._receiver = None 174 | self._con = None # gatttool interactive session 175 | self._characteristics = {} 176 | self._running = threading.Event() 177 | self._address = None 178 | self._send_lock = threading.Lock() 179 | 180 | def sendline(self, command): 181 | """ 182 | send a raw command to gatttool 183 | """ 184 | with self._send_lock: 185 | self._con.sendline(command) 186 | 187 | def supports_unbonded(self): 188 | return False 189 | 190 | def start(self, reset_on_start=True): 191 | if self._con and self._running.is_set(): 192 | self.stop() 193 | 194 | self._running.set() 195 | 196 | if reset_on_start: 197 | # Without restarting, sometimes when trying to bond with the 198 | # GATTTool backend, the entire computer will lock up. 199 | self.reset() 200 | 201 | # Start gatttool interactive session for device 202 | args = [ 203 | 'gatttool', 204 | self._cli_options, 205 | '-i', 206 | self._hci_device, 207 | '-I' 208 | ] 209 | gatttool_cmd = ' '.join([arg for arg in args if arg]) 210 | log.debug('gatttool_cmd=%s', gatttool_cmd) 211 | self._con = pexpect.spawn(gatttool_cmd, logfile=self._gatttool_logfile) 212 | # Wait for response 213 | self._con.expect(r'\[LE\]>', timeout=1) 214 | 215 | # Start the notification receiving thread 216 | self._receiver = GATTToolReceiver(self._con, self._running) 217 | self._receiver.daemon = True 218 | self._receiver.register_callback("disconnected", self._disconnect) 219 | for event in ["notification", "indication"]: 220 | self._receiver.register_callback( 221 | event, 222 | self._handle_notification_string 223 | ) 224 | self._receiver.start() 225 | 226 | def stop(self): 227 | """ 228 | Disconnects any connected device, stops the backgroud receiving thread 229 | and closes the spawned gatttool process. 230 | disconnect. 231 | """ 232 | self.disconnect(self._connected_device) 233 | if self._running.is_set(): 234 | log.info('Stopping') 235 | self._running.clear() 236 | 237 | if self._con and self._con.isalive(): 238 | while True: 239 | if not self._con.isalive(): 240 | break 241 | self.sendline('exit') 242 | time.sleep(0.1) 243 | self._con.close() 244 | self._con = None 245 | 246 | def scan(self, timeout=10, run_as_root=False): 247 | """ 248 | By default, scanning with gatttool requires root privileges. 249 | If you don't want to require root, you must add a few 250 | 'capabilities' to your system. If you have libcap installed, run this to 251 | enable normal users to perform LE scanning: 252 | setcap 'cap_net_raw,cap_net_admin+eip' `which hcitool` 253 | 254 | If you do use root, the hcitool subprocess becomes more difficult to 255 | terminate cleanly, and may leave your Bluetooth adapter in a bad state. 256 | """ 257 | 258 | cmd = 'hcitool lescan' 259 | if run_as_root: 260 | cmd = 'sudo %s' % cmd 261 | 262 | log.info("Starting BLE scan") 263 | scan = pexpect.spawn(cmd) 264 | # "lescan" doesn't exit, so we're forcing a timeout here: 265 | try: 266 | scan.expect('foooooo', timeout=timeout) 267 | except pexpect.EOF: 268 | message = "Unexpected error when scanning" 269 | if "No such device" in scan.before.decode('utf-8'): 270 | message = "No BLE adapter found" 271 | log.error(message) 272 | raise BLEError(message) 273 | except pexpect.TIMEOUT: 274 | devices = {} 275 | for line in scan.before.decode('utf-8').split('\r\n'): 276 | match = re.match( 277 | r'(([0-9A-Fa-f][0-9A-Fa-f]:?){6}) (\(?[\w]+\)?)', line) 278 | 279 | if match is not None: 280 | address = match.group(1) 281 | name = match.group(3) 282 | if name == "(unknown)": 283 | name = None 284 | 285 | if address in devices: 286 | if (devices[address]['name'] is None) and (name is not 287 | None): 288 | log.info("Discovered name of %s as %s", 289 | address, name) 290 | devices[address]['name'] = name 291 | else: 292 | log.info("Discovered %s (%s)", address, name) 293 | devices[address] = { 294 | 'address': address, 295 | 'name': name 296 | } 297 | log.info("Found %d BLE devices", len(devices)) 298 | return [device for device in devices.values()] 299 | return [] 300 | 301 | def connect(self, address, timeout=DEFAULT_CONNECT_TIMEOUT_S, 302 | address_type='public'): 303 | log.info('Connecting with timeout=%s', timeout) 304 | self.sendline('sec-level low') 305 | self._address = address 306 | 307 | try: 308 | cmd = 'connect {0} {1}'.format(self._address, address_type) 309 | with self._receiver.event("connect", timeout): 310 | self.sendline(cmd) 311 | except NotificationTimeout: 312 | message = "Timed out connecting to {0} after {1} seconds.".format( 313 | self._address, timeout 314 | ) 315 | log.error(message) 316 | raise NotConnectedError(message) 317 | 318 | self._connected_device = GATTToolBLEDevice(address, self) 319 | return self._connected_device 320 | 321 | def clear_bond(self, address=None): 322 | """Use the 'bluetoothctl' program to erase a stored BLE bond. 323 | """ 324 | con = pexpect.spawn('sudo bluetoothctl') 325 | con.expect("bluetooth", timeout=1) 326 | 327 | log.info("Clearing bond for %s", address) 328 | con.sendline("remove " + address.upper()) 329 | try: 330 | con.expect( 331 | ["Device has been removed", "# "], 332 | timeout=.5 333 | ) 334 | except pexpect.TIMEOUT: 335 | log.error("Unable to remove bonds for %s: %s", 336 | address, con.before) 337 | log.info("Removed bonds for %s", address) 338 | 339 | def _disconnect(self, event): 340 | try: 341 | self.disconnect(self._connected_device) 342 | except NotConnectedError: 343 | pass 344 | 345 | @at_most_one_device 346 | def disconnect(self, *args, **kwargs): 347 | # TODO with gattool from bluez 5.35, gatttol consumes 100% CPU after 348 | # sending "disconnect". If you let the remote device do the 349 | # disconnect, it doesn't. Leaving it commented out for now. 350 | if not self._receiver.is_set("disconnected"): 351 | self.sendline('disconnect') 352 | self._connected_device = None 353 | # TODO make call a disconnected callback on the device, so the device 354 | # knows if it was async disconnected? 355 | 356 | @at_most_one_device 357 | def bond(self, *args, **kwargs): 358 | log.info('Bonding') 359 | self.sendline('sec-level medium') 360 | 361 | def _save_charecteristic_callback(self, event): 362 | match = event["match"] 363 | try: 364 | value_handle = int(match.group(2), 16) 365 | char_uuid = match.group(3).strip().decode('ascii') 366 | self._characteristics[UUID(char_uuid)] = Characteristic( 367 | char_uuid, value_handle 368 | ) 369 | log.debug( 370 | "Found characteristic %s, value handle: 0x%x", 371 | char_uuid, 372 | value_handle 373 | ) 374 | except AttributeError: 375 | pass 376 | 377 | @at_most_one_device 378 | def discover_characteristics(self): 379 | self._characteristics = {} 380 | self._receiver.register_callback( 381 | "discover", 382 | self._save_charecteristic_callback, 383 | ) 384 | self.sendline('characteristics') 385 | 386 | max_time = time.time() + 5 387 | while not self._characteristics and time.time() < max_time: 388 | time.sleep(.5) 389 | 390 | # Sleep one extra second in case we caught characteristic 391 | # in the middle 392 | time.sleep(1) 393 | 394 | if not self._characteristics: 395 | raise NotConnectedError("Characteristic discovery failed") 396 | 397 | return self._characteristics 398 | 399 | def _handle_notification_string(self, event): 400 | msg = event["after"] 401 | hex_handle, _, hex_values = msg.strip().split(None, 5)[3:] 402 | handle = int(hex_handle, 16) 403 | values = bytearray(hex_values.replace(" ", "").decode("hex")) 404 | if self._connected_device is not None: 405 | self._connected_device.receive_notification(handle, values) 406 | 407 | @at_most_one_device 408 | def char_write_handle(self, handle, value, wait_for_response=False, minimum_wait_time=-1): 409 | """ 410 | Writes a value to a given characteristic handle. 411 | :param handle: 412 | :param value: 413 | :param wait_for_response: 414 | """ 415 | cmd = 'char-write-{0} 0x{1:02x} {2}'.format( 416 | 'req' if wait_for_response else 'cmd', 417 | handle, 418 | ''.join("{0:02x}".format(byte) for byte in value), 419 | ) 420 | 421 | log.debug('Sending cmd=%s', cmd) 422 | if wait_for_response: 423 | self._receiver.clear("char_written") 424 | self._receiver.clear("indication") 425 | self.sendline(cmd) 426 | try: 427 | self._receiver.wait("char_written", timeout=2) 428 | waitingForIndications = True 429 | while waitingForIndications == True: 430 | try: 431 | self._receiver.wait("indication", timeout=2) 432 | self._receiver.clear("indication") 433 | except NotificationTimeout: 434 | waitingForIndications = False 435 | except NotificationTimeout: 436 | log.error("No response received", exc_info=True) 437 | raise 438 | else: 439 | self.sendline(cmd) 440 | 441 | log.info('Sent cmd=%s', cmd) 442 | 443 | @at_most_one_device 444 | def char_read(self, uuid): 445 | """ 446 | Reads a Characteristic by uuid. 447 | :param uuid: UUID of Characteristic to read. 448 | :type uuid: str 449 | :return: bytearray of result. 450 | :rtype: bytearray 451 | """ 452 | with self._receiver.event("value", timeout=1): 453 | self.sendline('char-read-uuid %s' % uuid) 454 | rval = self._receiver.last_value("value", "after").split()[1:] 455 | return bytearray([int(x, 16) for x in rval]) 456 | 457 | def reset(self): 458 | subprocess.Popen(["sudo", "systemctl", "restart", "bluetooth"]).wait() 459 | subprocess.Popen([ 460 | "sudo", "hciconfig", self._hci_device, "reset"]).wait() 461 | -------------------------------------------------------------------------------- /nuki.py: -------------------------------------------------------------------------------- 1 | import nacl.utils 2 | import pygatt.backends 3 | import array 4 | from nacl.public import PrivateKey, Box 5 | from byteswap import ByteSwapper 6 | from crc import CrcCalculator 7 | import nuki_messages 8 | import sys 9 | import ConfigParser 10 | import blescan 11 | import bluetooth._bluetooth as bluez 12 | 13 | class Nuki(): 14 | # creates BLE connection with NUKI 15 | # -macAddress: bluetooth mac-address of your Nuki Lock 16 | def __init__(self, macAddress, cfg='/home/pi/nuki/nuki.cfg'): 17 | self._charWriteResponse = "" 18 | self.parser = nuki_messages.NukiCommandParser() 19 | self.crcCalculator = CrcCalculator() 20 | self.byteSwapper = ByteSwapper() 21 | self.macAddress = macAddress 22 | self.config = ConfigParser.RawConfigParser() 23 | self.config.read(cfg) 24 | self.device = None 25 | 26 | def _makeBLEConnection(self): 27 | if self.device == None: 28 | adapter = pygatt.backends.GATTToolBackend() 29 | nukiBleConnectionReady = False 30 | while nukiBleConnectionReady == False: 31 | print "Starting BLE adapter..." 32 | adapter.start() 33 | print "Init Nuki BLE connection..." 34 | try : 35 | self.device = adapter.connect(self.macAddress) 36 | nukiBleConnectionReady = True 37 | except: 38 | print "Unable to connect, retrying..." 39 | print "Nuki BLE connection established" 40 | 41 | def isNewNukiStateAvailable(self): 42 | if self.device != None: 43 | self.device.disconnect() 44 | self.device = None 45 | dev_id = 0 46 | try: 47 | sock = bluez.hci_open_dev(dev_id) 48 | except: 49 | print "error accessing bluetooth device..." 50 | sys.exit(1) 51 | blescan.hci_le_set_scan_parameters(sock) 52 | blescan.hci_enable_le_scan(sock) 53 | returnedList = blescan.parse_events(sock, 10) 54 | newStateAvailable = -1 55 | print "isNewNukiStateAvailable() -> search through %d received beacons..." % len(returnedList) 56 | for beacon in returnedList: 57 | beaconElements = beacon.split(',') 58 | if beaconElements[0] == self.macAddress.lower() and beaconElements[1] == "a92ee200550111e4916c0800200c9a66": 59 | print "Nuki beacon found, new state element: %s" % beaconElements[4] 60 | if beaconElements[4] == '-60': 61 | newStateAvailable = 0 62 | else: 63 | newStateAvailable = 1 64 | break 65 | else: 66 | print "non-Nuki beacon found: mac=%s, signature=%s" % (beaconElements[0],beaconElements[1]) 67 | print "isNewNukiStateAvailable() -> result=%d" % newStateAvailable 68 | return newStateAvailable 69 | 70 | # private method to handle responses coming back from the Nuki Lock over the BLE connection 71 | def _handleCharWriteResponse(self, handle, value): 72 | self._charWriteResponse += "".join(format(x, '02x') for x in value) 73 | 74 | # method to authenticate yourself (only needed the very first time) to the Nuki Lock 75 | # -publicKeyHex: a public key (as hex string) you created to talk with the Nuki Lock 76 | # -privateKeyHex: a private key (complementing the public key, described above) you created to talk with the Nuki Lock 77 | # -ID : a unique number to identify yourself to the Nuki Lock 78 | # -IDType : '00' for 'app', '01' for 'bridge' and '02' for 'fob' 79 | # -name : a unique name to identify yourself to the Nuki Lock (will also appear in the logs of the Nuki Lock) 80 | def authenticateUser(self, publicKeyHex, privateKeyHex, ID, IDType, name): 81 | self._makeBLEConnection() 82 | self.config.remove_section(self.macAddress) 83 | self.config.add_section(self.macAddress) 84 | pairingHandle = self.device.get_handle('a92ee101-5501-11e4-916c-0800200c9a66') 85 | print "Nuki Pairing UUID handle created: %04x" % pairingHandle 86 | publicKeyReq = nuki_messages.Nuki_REQ('0003') 87 | self.device.subscribe('a92ee101-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse) 88 | publicKeyReqCommand = publicKeyReq.generate() 89 | self._charWriteResponse = "" 90 | print "Requesting Nuki Public Key using command: %s" % publicKeyReq.show() 91 | self.device.char_write_handle(pairingHandle,publicKeyReqCommand,True,2) 92 | print "Nuki Public key requested" 93 | commandParsed = self.parser.parse(self._charWriteResponse) 94 | if self.parser.isNukiCommand(self._charWriteResponse) == False: 95 | sys.exit("Error while requesting public key: %s" % commandParsed) 96 | if commandParsed.command != '0003': 97 | sys.exit("Nuki returned unexpected response (expecting PUBLIC_KEY): %s" % commandParsed.show()) 98 | publicKeyNuki = commandParsed.publicKey 99 | self.config.set(self.macAddress,'publicKeyNuki',publicKeyNuki) 100 | self.config.set(self.macAddress,'publicKeyHex',publicKeyHex) 101 | self.config.set(self.macAddress,'privateKeyHex',privateKeyHex) 102 | self.config.set(self.macAddress,'ID',ID) 103 | self.config.set(self.macAddress,'IDType',IDType) 104 | self.config.set(self.macAddress,'Name',name) 105 | print "Public key received: %s" % commandParsed.publicKey 106 | publicKeyPush = nuki_messages.Nuki_PUBLIC_KEY(publicKeyHex) 107 | publicKeyPushCommand = publicKeyPush.generate() 108 | print "Pushing Public Key using command: %s" % publicKeyPush.show() 109 | self._charWriteResponse = "" 110 | self.device.char_write_handle(pairingHandle,publicKeyPushCommand,True,5) 111 | print "Public key pushed" 112 | commandParsed = self.parser.parse(self._charWriteResponse) 113 | if self.parser.isNukiCommand(self._charWriteResponse) == False: 114 | sys.exit("Error while pushing public key: %s" % commandParsed) 115 | if commandParsed.command != '0004': 116 | sys.exit("Nuki returned unexpected response (expecting CHALLENGE): %s" % commandParsed.show()) 117 | print "Challenge received: %s" % commandParsed.nonce 118 | nonceNuki = commandParsed.nonce 119 | authAuthenticator = nuki_messages.Nuki_AUTH_AUTHENTICATOR() 120 | authAuthenticator.createPayload(nonceNuki, privateKeyHex, publicKeyHex, publicKeyNuki) 121 | authAuthenticatorCommand = authAuthenticator.generate() 122 | self._charWriteResponse = "" 123 | self.device.char_write_handle(pairingHandle,authAuthenticatorCommand,True,5) 124 | print "Authorization Authenticator sent: %s" % authAuthenticator.show() 125 | commandParsed = self.parser.parse(self._charWriteResponse) 126 | if self.parser.isNukiCommand(self._charWriteResponse) == False: 127 | sys.exit("Error while sending Authorization Authenticator: %s" % commandParsed) 128 | if commandParsed.command != '0004': 129 | sys.exit("Nuki returned unexpected response (expecting CHALLENGE): %s" % commandParsed.show()) 130 | print "Challenge received: %s" % commandParsed.nonce 131 | nonceNuki = commandParsed.nonce 132 | authData = nuki_messages.Nuki_AUTH_DATA() 133 | authData.createPayload(publicKeyNuki, privateKeyHex, publicKeyHex, nonceNuki, ID, IDType, name) 134 | authDataCommand = authData.generate() 135 | self._charWriteResponse = "" 136 | self.device.char_write_handle(pairingHandle,authDataCommand,True,7) 137 | print "Authorization Data sent: %s" % authData.show() 138 | commandParsed = self.parser.parse(self._charWriteResponse) 139 | if self.parser.isNukiCommand(self._charWriteResponse) == False: 140 | sys.exit("Error while sending Authorization Data: %s" % commandParsed) 141 | if commandParsed.command != '0007': 142 | sys.exit("Nuki returned unexpected response (expecting AUTH_ID): %s" % commandParsed.show()) 143 | print "Authorization ID received: %s" % commandParsed.show() 144 | nonceNuki = commandParsed.nonce 145 | authorizationID = commandParsed.authID 146 | self.config.set(self.macAddress,'authorizationID',authorizationID) 147 | authId = int(commandParsed.authID,16) 148 | authIDConfirm = nuki_messages.Nuki_AUTH_ID_CONFIRM() 149 | authIDConfirm.createPayload(publicKeyNuki, privateKeyHex, publicKeyHex, nonceNuki, authId) 150 | authIDConfirmCommand = authIDConfirm.generate() 151 | self._charWriteResponse = "" 152 | self.device.char_write_handle(pairingHandle,authIDConfirmCommand,True,7) 153 | print "Authorization ID Confirmation sent: %s" % authIDConfirm.show() 154 | commandParsed = self.parser.parse(self._charWriteResponse) 155 | if self.parser.isNukiCommand(self._charWriteResponse) == False: 156 | sys.exit("Error while sending Authorization ID Confirmation: %s" % commandParsed) 157 | if commandParsed.command != '000E': 158 | sys.exit("Nuki returned unexpected response (expecting STATUS): %s" % commandParsed.show()) 159 | print "STATUS received: %s" % commandParsed.status 160 | with open('/home/pi/nuki/nuki.cfg', 'wb') as configfile: 161 | self.config.write(configfile) 162 | return commandParsed.status 163 | 164 | # method to read the current lock state of the Nuki Lock 165 | def readLockState(self): 166 | self._makeBLEConnection() 167 | keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") 168 | self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse) 169 | stateReq = nuki_messages.Nuki_REQ('000C') 170 | stateReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=stateReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 171 | stateReqEncryptedCommand = stateReqEncrypted.generate() 172 | self._charWriteResponse = "" 173 | self.device.char_write_handle(keyturnerUSDIOHandle,stateReqEncryptedCommand,True,3) 174 | print "Nuki State Request sent: %s\nresponse received: %s" % (stateReq.show(),self._charWriteResponse) 175 | commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 176 | if self.parser.isNukiCommand(commandParsed) == False: 177 | sys.exit("Error while requesting Nuki STATES: %s" % commandParsed) 178 | commandParsed = self.parser.parse(commandParsed) 179 | if commandParsed.command != '000C': 180 | sys.exit("Nuki returned unexpected response (expecting Nuki STATES): %s" % commandParsed.show()) 181 | print "%s" % commandParsed.show() 182 | return commandParsed 183 | 184 | # method to perform a lock action on the Nuki Lock: 185 | # -lockAction: 'UNLOCK', 'LOCK', 'UNLATCH', 'LOCKNGO', 'LOCKNGO_UNLATCH', 'FOB_ACTION_1', 'FOB_ACTION_2' or 'FOB_ACTION_3' 186 | def lockAction(self,lockAction): 187 | self._makeBLEConnection() 188 | keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") 189 | self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse) 190 | challengeReq = nuki_messages.Nuki_REQ('0004') 191 | challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 192 | challengeReqEncryptedCommand = challengeReqEncrypted.generate() 193 | self._charWriteResponse = "" 194 | self.device.char_write_handle(keyturnerUSDIOHandle,challengeReqEncryptedCommand,True,4) 195 | print "Nuki CHALLENGE Request sent: %s" % challengeReq.show() 196 | commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 197 | if self.parser.isNukiCommand(commandParsed) == False: 198 | sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) 199 | commandParsed = self.parser.parse(commandParsed) 200 | if commandParsed.command != '0004': 201 | sys.exit("Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) 202 | print "Challenge received: %s" % commandParsed.nonce 203 | lockActionReq = nuki_messages.Nuki_LOCK_ACTION() 204 | lockActionReq.createPayload(self.config.getint(self.macAddress, 'ID'), lockAction, commandParsed.nonce) 205 | lockActionReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=lockActionReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 206 | lockActionReqEncryptedCommand = lockActionReqEncrypted.generate() 207 | self._charWriteResponse = "" 208 | self.device.char_write_handle(keyturnerUSDIOHandle,lockActionReqEncryptedCommand,True,4) 209 | print "Nuki Lock Action Request sent: %s" % lockActionReq.show() 210 | commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 211 | if self.parser.isNukiCommand(commandParsed) == False: 212 | sys.exit("Error while requesting Nuki Lock Action: %s" % commandParsed) 213 | commandParsed = self.parser.parse(commandParsed) 214 | if commandParsed.command != '000C' and commandParsed.command != '000E': 215 | sys.exit("Nuki returned unexpected response (expecting Nuki STATUS/STATES): %s" % commandParsed.show()) 216 | print "%s" % commandParsed.show() 217 | 218 | # method to fetch the number of log entries from your Nuki Lock 219 | # -pinHex : a 2-byte hex string representation of the PIN code you have set on your Nuki Lock (default is 0000) 220 | def getLogEntriesCount(self, pinHex): 221 | self._makeBLEConnection() 222 | keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") 223 | self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse) 224 | challengeReq = nuki_messages.Nuki_REQ('0004') 225 | challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 226 | challengeReqEncryptedCommand = challengeReqEncrypted.generate() 227 | self._charWriteResponse = "" 228 | print "Requesting CHALLENGE: %s" % challengeReqEncrypted.generate("HEX") 229 | self.device.char_write_handle(keyturnerUSDIOHandle,challengeReqEncryptedCommand,True,5) 230 | print "Nuki CHALLENGE Request sent: %s" % challengeReq.show() 231 | commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 232 | if self.parser.isNukiCommand(commandParsed) == False: 233 | sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) 234 | commandParsed = self.parser.parse(commandParsed) 235 | if commandParsed.command != '0004': 236 | sys.exit("Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) 237 | print "Challenge received: %s" % commandParsed.nonce 238 | logEntriesReq = nuki_messages.Nuki_LOG_ENTRIES_REQUEST() 239 | logEntriesReq.createPayload(0, commandParsed.nonce, self.byteSwapper.swap(pinHex)) 240 | logEntriesReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=logEntriesReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 241 | logEntriesReqEncryptedCommand = logEntriesReqEncrypted.generate() 242 | self._charWriteResponse = "" 243 | self.device.char_write_handle(keyturnerUSDIOHandle,logEntriesReqEncryptedCommand,True,4) 244 | print "Nuki Log Entries Request sent: %s" % logEntriesReq.show() 245 | commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 246 | if self.parser.isNukiCommand(commandParsed) == False: 247 | sys.exit("Error while requesting Nuki Log Entries: %s" % commandParsed) 248 | commandParsed = self.parser.parse(commandParsed) 249 | if commandParsed.command != '0026': 250 | sys.exit("Nuki returned unexpected response (expecting Nuki LOG ENTRY): %s" % commandParsed.show()) 251 | print "%s" % commandParsed.show() 252 | return int(commandParsed.logCount, 16) 253 | 254 | # method to fetch the most recent log entries from your Nuki Lock 255 | # -count: the number of entries you would like to fetch (if available) 256 | # -pinHex : a 2-byte hex string representation of the PIN code you have set on your Nuki Lock (default is 0000) 257 | def getLogEntries(self,count,pinHex): 258 | self._makeBLEConnection() 259 | keyturnerUSDIOHandle = self.device.get_handle("a92ee202-5501-11e4-916c-0800200c9a66") 260 | self.device.subscribe('a92ee202-5501-11e4-916c-0800200c9a66', self._handleCharWriteResponse) 261 | challengeReq = nuki_messages.Nuki_REQ('0004') 262 | challengeReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=challengeReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 263 | challengeReqEncryptedCommand = challengeReqEncrypted.generate() 264 | print "Requesting CHALLENGE: %s" % challengeReqEncrypted.generate("HEX") 265 | self._charWriteResponse = "" 266 | self.device.char_write_handle(keyturnerUSDIOHandle,challengeReqEncryptedCommand,True,5) 267 | print "Nuki CHALLENGE Request sent: %s" % challengeReq.show() 268 | commandParsed = self.parser.decrypt(self._charWriteResponse,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 269 | if self.parser.isNukiCommand(commandParsed) == False: 270 | sys.exit("Error while requesting Nuki CHALLENGE: %s" % commandParsed) 271 | commandParsed = self.parser.parse(commandParsed) 272 | if commandParsed.command != '0004': 273 | sys.exit("Nuki returned unexpected response (expecting Nuki CHALLENGE): %s" % commandParsed.show()) 274 | print "Challenge received: %s" % commandParsed.nonce 275 | logEntriesReq = nuki_messages.Nuki_LOG_ENTRIES_REQUEST() 276 | logEntriesReq.createPayload(count, commandParsed.nonce, self.byteSwapper.swap(pinHex)) 277 | logEntriesReqEncrypted = nuki_messages.Nuki_EncryptedCommand(authID=self.config.get(self.macAddress, 'authorizationID'), nukiCommand=logEntriesReq, publicKey=self.config.get(self.macAddress, 'publicKeyNuki'), privateKey=self.config.get(self.macAddress, 'privateKeyHex')) 278 | logEntriesReqEncryptedCommand = logEntriesReqEncrypted.generate() 279 | self._charWriteResponse = "" 280 | self.device.char_write_handle(keyturnerUSDIOHandle,logEntriesReqEncryptedCommand,True,6) 281 | print "Nuki Log Entries Request sent: %s" % logEntriesReq.show() 282 | messages = self.parser.splitEncryptedMessages(self._charWriteResponse) 283 | print "Received %d messages" % len(messages) 284 | logMessages = [] 285 | for message in messages: 286 | print "Decrypting message %s" % message 287 | try: 288 | commandParsed = self.parser.decrypt(message,self.config.get(self.macAddress, 'publicKeyNuki'),self.config.get(self.macAddress, 'privateKeyHex'))[8:] 289 | if self.parser.isNukiCommand(commandParsed) == False: 290 | sys.exit("Error while requesting Nuki Log Entries: %s" % commandParsed) 291 | commandParsed = self.parser.parse(commandParsed) 292 | if commandParsed.command != '0024' and commandParsed.command != '0026' and commandParsed.command != '000E': 293 | sys.exit("Nuki returned unexpected response (expecting Nuki LOG ENTRY): %s" % commandParsed.show()) 294 | print "%s" % commandParsed.show() 295 | if commandParsed.command == '0024': 296 | logMessages.append(commandParsed) 297 | except: 298 | print "Unable to decrypt message" 299 | return logMessages 300 | 301 | -------------------------------------------------------------------------------- /nuki_messages.py: -------------------------------------------------------------------------------- 1 | from crc import CrcCalculator 2 | from byteswap import ByteSwapper 3 | import array 4 | import nacl.utils 5 | import nacl.secret 6 | from nacl.public import PrivateKey, Box 7 | from nacl.bindings.crypto_box import crypto_box_beforenm 8 | import hmac 9 | import hashlib 10 | 11 | class Nuki_EncryptedCommand(object): 12 | def __init__(self, authID='', nukiCommand=None, nonce='', publicKey='', privateKey=''): 13 | self.byteSwapper = ByteSwapper() 14 | self.crcCalculator = CrcCalculator() 15 | self.authID = authID 16 | self.command = nukiCommand 17 | self.nonce = nonce 18 | if nonce == '': 19 | self.nonce = nacl.utils.random(24).encode("hex") 20 | self.publicKey = publicKey 21 | self.privateKey = privateKey 22 | 23 | def generate(self, format='BYTE_ARRAY'): 24 | unencrypted = self.authID + self.command.generate(format='HEX')[:-4] 25 | crc = self.byteSwapper.swap(self.crcCalculator.crc_ccitt(unencrypted)) 26 | unencrypted = unencrypted + crc 27 | sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(self.publicKey)),bytes(bytearray.fromhex(self.privateKey))).encode("hex") 28 | box = nacl.secret.SecretBox(bytes(bytearray.fromhex(sharedKey))) 29 | encrypted = box.encrypt(bytes(bytearray.fromhex(unencrypted)), bytes(bytearray.fromhex(self.nonce))).encode("hex")[48:] 30 | length = self.byteSwapper.swap("%04X" % (len(encrypted)/2)) 31 | msg = self.nonce + self.authID + length + encrypted 32 | if format == 'BYTE_ARRAY': 33 | return array.array('B',msg.decode("hex")) 34 | else: 35 | return msg 36 | 37 | class Nuki_Command(object): 38 | def __init__(self, payload=""): 39 | self.crcCalculator = CrcCalculator() 40 | self.byteSwapper = ByteSwapper() 41 | self.parser = NukiCommandParser() 42 | self.command = '' 43 | self.payload = payload 44 | 45 | def generate(self, format='BYTE_ARRAY'): 46 | msg = self.byteSwapper.swap(self.command) + self.payload 47 | crc = self.byteSwapper.swap(self.crcCalculator.crc_ccitt(msg)) 48 | msg = msg + crc 49 | if format == 'BYTE_ARRAY': 50 | return array.array('B',msg.decode("hex")) 51 | else: 52 | return msg 53 | 54 | def isError(self): 55 | return self.command == '0012' 56 | 57 | class Nuki_REQ(Nuki_Command): 58 | def __init__(self, payload="N/A"): 59 | super(self.__class__, self).__init__(payload) 60 | self.command = '0001' 61 | self.payload = self.byteSwapper.swap(payload) 62 | 63 | def show(self): 64 | payloadParsed = self.parser.getNukiCommandText(self.byteSwapper.swap(self.payload)) 65 | return "Nuki_REQ\n\tPayload: %s" % payloadParsed 66 | 67 | class Nuki_ERROR(Nuki_Command): 68 | def __init__(self, payload="N/A"): 69 | super(self.__class__, self).__init__(payload) 70 | self.command = '0012' 71 | self.errorCode = ''; 72 | self.commandIdentifier = ''; 73 | if payload != "N/A": 74 | self.errorCode = payload[:2] 75 | self.commandIdentifier = self.byteSwapper.swap(payload[2:6]) 76 | 77 | def show(self): 78 | payloadParsed = self.parser.getNukiCommandText(self.byteSwapper.swap(self.payload)) 79 | return "Nuki_ERROR\n\tError Code: %s\n\tCommand Identifier: %s" % (self.errorCode,self.commandIdentifier) 80 | 81 | class Nuki_PUBLIC_KEY(Nuki_Command): 82 | def __init__(self, payload="N/A"): 83 | super(self.__class__, self).__init__(payload) 84 | self.command = '0003' 85 | self.publicKey = ''; 86 | if payload != "N/A": 87 | self.publicKey = payload 88 | 89 | def show(self): 90 | return "Nuki_PUBLIC_KEY\n\tKey: %s" % (self.publicKey) 91 | 92 | class Nuki_CHALLENGE(Nuki_Command): 93 | def __init__(self, payload="N/A"): 94 | super(self.__class__, self).__init__(payload) 95 | self.command = '0004' 96 | self.nonce = ''; 97 | if payload != "N/A": 98 | self.nonce = payload 99 | 100 | def show(self): 101 | return "Nuki_CHALLENGE\n\tNonce: %s" % (self.nonce) 102 | 103 | class Nuki_AUTH_AUTHENTICATOR(Nuki_Command): 104 | def __init__(self, payload="N/A"): 105 | super(self.__class__, self).__init__(payload) 106 | self.command = '0005' 107 | self.authenticator = '' 108 | if payload != "N/A": 109 | self.authenticator = payload 110 | 111 | def createPayload(self, nonceNuki, privateKeyAuth, publicKeyAuth, publicKeyNuki): 112 | sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(publicKeyNuki)),bytes(bytearray.fromhex(privateKeyAuth))).encode("hex") 113 | valueR = publicKeyAuth + publicKeyNuki + nonceNuki 114 | self.authenticator = hmac.new(bytearray.fromhex(sharedKey), msg=bytearray.fromhex(valueR), digestmod=hashlib.sha256).hexdigest() 115 | self.payload = self.authenticator 116 | 117 | def show(self): 118 | return "Nuki_AUTH_AUTHENTICATOR\n\tAuthenticator: %s" % (self.authenticator) 119 | 120 | class Nuki_AUTH_DATA(Nuki_Command): 121 | def __init__(self, payload="N/A"): 122 | super(self.__class__, self).__init__(payload) 123 | self.command = '0006' 124 | self.authenticator = '' 125 | self.idType = '01' 126 | self.appID = '' 127 | self.name = '' 128 | self.nonce = '' 129 | if payload != "N/A": 130 | self.authenticator = payload[:64] 131 | self.idType = payload[64:66] 132 | self.appID = payload[66:74] 133 | self.name = payload[74:138] 134 | self.nonce = payload[138:] 135 | 136 | def createPayload(self, publicKeyNuki, privateKeyAuth, publicKeyAuth, nonceNuki, appID, idType, name): 137 | self.appID = ("%x" % appID).rjust(8,'0') 138 | self.idType = idType 139 | self.name = name.encode("hex").ljust(64, '0') 140 | self.nonce = nacl.utils.random(32).encode("hex") 141 | sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(publicKeyNuki)),bytes(bytearray.fromhex(privateKeyAuth))).encode("hex") 142 | valueR = self.idType + self.appID + self.name + self.nonce + nonceNuki 143 | self.authenticator = hmac.new(bytearray.fromhex(sharedKey), msg=bytearray.fromhex(valueR), digestmod=hashlib.sha256).hexdigest() 144 | self.payload = self.authenticator + self.idType + self.appID + self.name + self.nonce 145 | 146 | def show(self): 147 | return "Nuki_AUTH_DATA\n\tAuthenticator: %s\n\tID Type: %s\n\tAuthenticator ID: %s\n\tName: %s\n\tNonce: %s" % (self.authenticator, self.idType, self.appID, self.name.decode("hex"), self.nonce) 148 | 149 | class Nuki_AUTH_ID(Nuki_Command): 150 | def __init__(self, payload="N/A"): 151 | super(self.__class__, self).__init__(payload) 152 | self.command = '0007' 153 | self.authenticator = '' 154 | self.authID = '' 155 | self.uuid = '' 156 | self.nonce = '' 157 | if payload != "N/A": 158 | self.authenticator = payload[:64] 159 | self.authID = payload[64:72] 160 | self.uuid = payload[72:104] 161 | self.nonce = payload[104:] 162 | 163 | def show(self): 164 | return "Nuki_AUTH_ID\n\tAuthenticator: %s\n\tAuthorization ID: %s\n\tUUID: %s\n\tNonce: %s" % (self.authenticator, self.authID, self.uuid, self.nonce) 165 | 166 | class Nuki_AUTH_ID_CONFIRM(Nuki_Command): 167 | def __init__(self, payload="N/A"): 168 | super(self.__class__, self).__init__(payload) 169 | self.command = '001E' 170 | self.authID = '' 171 | if payload != "N/A": 172 | self.authenticator = payload[:64] 173 | self.authID = payload[64:] 174 | 175 | def show(self): 176 | return "Nuki_AUTH_ID_CONFIRM\n\tAuthenticator: %s\n\tAuthorization ID: %s" % (self.authenticator, self.authID) 177 | 178 | def createPayload(self, publicKeyNuki, privateKeyAuth, publicKeyAuth, nonceNuki, authID): 179 | self.authID = ("%x" % authID).rjust(8,'0') 180 | sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(publicKeyNuki)),bytes(bytearray.fromhex(privateKeyAuth))).encode("hex") 181 | valueR = self.authID + nonceNuki 182 | self.authenticator = hmac.new(bytearray.fromhex(sharedKey), msg=bytearray.fromhex(valueR), digestmod=hashlib.sha256).hexdigest() 183 | self.payload = self.authenticator + self.authID 184 | 185 | class Nuki_STATUS(Nuki_Command): 186 | def __init__(self, payload="N/A"): 187 | super(self.__class__, self).__init__(payload) 188 | self.command = '000E' 189 | self.status = '' 190 | if payload != "N/A": 191 | self.status = payload 192 | 193 | def show(self): 194 | return "Nuki_STATUS\n\tStatus: %s" % (self.status) 195 | 196 | class Nuki_STATES(Nuki_Command): 197 | def __init__(self, payload="N/A"): 198 | super(self.__class__, self).__init__(payload) 199 | self.command = '000C' 200 | self.nukiState = '' 201 | self.lockState = '' 202 | self.trigger = '' 203 | self.currentTime = '' 204 | self.timeOffset = '' 205 | self.criticalBattery = '' 206 | if payload != "N/A": 207 | payload = payload.upper() 208 | self.nukiState = payload[:2] 209 | if self.nukiState == '00': 210 | self.nukiState = 'Uninitialized' 211 | elif self.nukiState == '01': 212 | self.nukiState = 'Pairing Mode' 213 | elif self.nukiState == '02': 214 | self.nukiState = 'Door Mode' 215 | self.lockState = payload[2:4] 216 | if self.lockState == '00': 217 | self.lockState = 'Uncalibrated' 218 | elif self.lockState == '01': 219 | self.lockState = 'Locked' 220 | elif self.lockState == '02': 221 | self.lockState = 'Unlocking' 222 | elif self.lockState == '03': 223 | self.lockState = 'Unlocked' 224 | elif self.lockState == '04': 225 | self.lockState = 'Locking' 226 | elif self.lockState == '05': 227 | self.lockState = 'Unlatched' 228 | elif self.lockState == '06': 229 | self.lockState = 'Unlocked (lockNGo)' 230 | elif self.lockState == '07': 231 | self.lockState = 'Unlatching' 232 | elif self.lockState == 'FE': 233 | self.lockState = 'Motor Blocked' 234 | elif self.lockState == 'FF': 235 | self.lockState = 'Undefined' 236 | self.trigger = payload[4:6] 237 | if self.trigger == '00': 238 | self.trigger = 'Bluetooth' 239 | elif self.trigger == '01': 240 | self.trigger = 'Manual' 241 | elif self.trigger == '02': 242 | self.trigger = 'Button' 243 | year = int(self.byteSwapper.swap(payload[6:10]),16) 244 | month = int(payload[10:12],16) 245 | day = int(payload[12:14],16) 246 | hour = int(payload[14:16],16) 247 | minute = int(payload[16:18],16) 248 | second = int(payload[18:20],16) 249 | self.currentTime = "%02d-%02d-%d %02d:%02d:%02d" % (day,month,year,hour,minute,second) 250 | self.timeOffset = int(self.byteSwapper.swap(payload[20:24]),16) 251 | self.criticalBattery = payload[24:26] 252 | if self.criticalBattery == '00': 253 | self.criticalBattery = 'OK' 254 | elif self.criticalBattery == '01': 255 | self.criticalBattery = 'Critical' 256 | 257 | def show(self): 258 | return "Nuki_STATES\n\tNuki Status: %s\n\tLock Status: %s\n\tTrigger: %s\n\tCurrent Time: %s\n\tTime Offset: %s\n\tCritical Battery: %s" % (self.nukiState,self.lockState,self.trigger,self.currentTime,self.timeOffset,self.criticalBattery) 259 | 260 | class Nuki_LOCK_ACTION(Nuki_Command): 261 | def __init__(self, payload="N/A"): 262 | super(self.__class__, self).__init__(payload) 263 | self.command = '000D' 264 | self.lockAction = '' 265 | self.appID = '' 266 | self.flags = '00' 267 | self.nonce = '' 268 | if payload != "N/A": 269 | self.authenticator = payload[:64] 270 | self.authID = payload[64:] 271 | 272 | def show(self): 273 | return "Nuki_LOCK_ACTION\n\tLock Action: %s\n\tAPP ID: %s\n\tFlags: %s\n\tNonce: %s" % (self.lockAction,self.appID,self.flags,self.nonce) 274 | 275 | def createPayload(self, appID, lockAction, nonce): 276 | self.appID = ("%x" % appID).rjust(8,'0') 277 | self.nonce = nonce 278 | if lockAction == 'UNLOCK': 279 | self.lockAction = '01' 280 | elif lockAction == 'LOCK': 281 | self.lockAction = '02' 282 | elif lockAction == 'UNLATCH': 283 | self.lockAction = '03' 284 | elif lockAction == 'LOCKNGO': 285 | self.lockAction = '04' 286 | elif lockAction == 'LOCKNGO_UNLATCH': 287 | self.lockAction = '05' 288 | elif lockAction == 'FOB_ACTION_1': 289 | self.lockAction = '81' 290 | elif lockAction == 'FOB_ACTION_2': 291 | self.lockAction = '82' 292 | elif lockAction == 'FOB_ACTION_3': 293 | self.lockAction = '83' 294 | else: 295 | sys.exit("Invalid Lock Action request: %s (should be one of these: 'UNLOCK', 'LOCK', 'UNLATCH', 'LOCKNGO', 'LOCKNGO_UNLATCH', 'FOB_ACTION_1', 'FOB_ACTION_2' or 'FOB_ACTION_3')'" % lockAction) 296 | self.payload = self.lockAction + self.appID + self.flags + self.nonce 297 | 298 | class Nuki_LOG_ENTRIES_REQUEST(Nuki_Command): 299 | def __init__(self, payload="N/A"): 300 | super(self.__class__, self).__init__(payload) 301 | self.command = '0023' 302 | self.mostRecent = '00' 303 | self.startIndex = '0000' 304 | self.count = '' 305 | self.nonce = '' 306 | self.pin = '' 307 | if payload != "N/A": 308 | self.mostRecent = payload[:2] 309 | self.startIndex = payload[2:6] 310 | self.count = payload[6:10] 311 | self.nonce = payload[10:74] 312 | self.pin = payload[74:] 313 | 314 | def show(self): 315 | return "Nuki_LOCK_ENTRIES_REQUEST\n\tMost Recent: %s\n\tStart Index: %s\n\tCount: %s\n\tNonce: %s\n\tPIN: %s" % (self.mostRecent,self.startIndex,self.count,self.nonce,self.pin) 316 | 317 | def createPayload(self, count, nonce, pin): 318 | self.mostRecent = '01' 319 | self.startIndex = self.byteSwapper.swap("%04x" % 0) 320 | self.count = self.byteSwapper.swap("%04x" % count) 321 | self.nonce = nonce 322 | self.pin = pin 323 | self.payload = self.mostRecent + self.startIndex + self.count + self.nonce + self.pin 324 | 325 | class Nuki_LOG_ENTRY_COUNT(Nuki_Command): 326 | def __init__(self, payload="N/A"): 327 | super(self.__class__, self).__init__(payload) 328 | self.command = '0026' 329 | self.logEnabled = '' 330 | self.logCount = '' 331 | if payload != "N/A": 332 | payload = payload.upper() 333 | self.logEnabled = payload[:2] 334 | if self.logEnabled == '00': 335 | self.logEnabled = 'DISABLED' 336 | elif self.logEnabled == '01': 337 | self.logEnabled = 'ENABLED' 338 | self.logCount = self.byteSwapper.swap(payload[2:6]) 339 | 340 | def show(self): 341 | return "Nuki_LOG_ENTRY_COUNT\n\tLOG: %s\n\tCount: %d" % (self.logEnabled, int(self.logCount, 16)) 342 | 343 | class Nuki_LOG_ENTRY(Nuki_Command): 344 | def __init__(self, payload="N/A"): 345 | super(self.__class__, self).__init__(payload) 346 | self.command = '0024' 347 | self.index = '' 348 | self.timestamp = '' 349 | self.name = '' 350 | self.type = '' 351 | self.data = '' 352 | if payload != "N/A": 353 | payload = payload.upper() 354 | self.index = int(self.byteSwapper.swap(payload[:4]),16) 355 | year = int(self.byteSwapper.swap(payload[4:8]),16) 356 | month = int(payload[8:10],16) 357 | day = int(payload[10:12],16) 358 | hour = int(payload[12:14],16) 359 | minute = int(payload[14:16],16) 360 | second = int(payload[16:18],16) 361 | self.timestamp = "%02d-%02d-%d %02d:%02d:%02d" % (day,month,year,hour,minute,second) 362 | self.name = payload[18:82] 363 | self.type = payload[82:84] 364 | if self.type == '01': 365 | self.type = 'LOG' 366 | self.data = payload[84:86] 367 | if self.data == '00': 368 | self.data = 'DISABLED' 369 | elif self.data == '01': 370 | self.data = 'ENABLED' 371 | elif self.type == '02': 372 | self.type = 'LOCK' 373 | lockAction = payload[84:86] 374 | if lockAction == '01': 375 | self.data = 'UNLOCK' 376 | elif lockAction == '02': 377 | self.data = 'LOCK' 378 | elif lockAction == '03': 379 | self.data = 'UNLATCH' 380 | elif lockAction == '04': 381 | self.data = 'LOCKNGO' 382 | elif lockAction == '05': 383 | self.data = 'LOCKNGO_UNLATCH' 384 | elif lockAction == '81': 385 | self.data = 'FOB_ACTION_1' 386 | elif lockAction == '82': 387 | self.data = 'FOB_ACTION_2' 388 | elif lockAction == '83': 389 | self.data = 'FOB_ACTION_3' 390 | trigger = payload[86:88] 391 | if trigger == '00': 392 | self.data = "%s - via Bluetooth" % self.data 393 | elif trigger == '01': 394 | self.data = "%s - manual" % self.data 395 | self.name = "N/A".encode("hex") 396 | elif trigger == '02': 397 | self.data = "%s - via button" % self.data 398 | self.name = "N/A".encode("hex") 399 | 400 | def show(self): 401 | return "Nuki_LOG_ENTRY\n\tIndex: %d\n\tTimestamp: %s\n\tName: %s\n\tType: %s\n\tData: %s" % (self.index, self.timestamp, self.name.decode("hex"), self.type, self.data) 402 | 403 | class NukiCommandParser: 404 | def __init__(self): 405 | self.byteSwapper = ByteSwapper() 406 | self.commandList = ['0001','0003','0004','0005','0006','0007','000C','001E','000E','0023','0024','0026','0012'] 407 | 408 | def isNukiCommand(self, commandString): 409 | command = self.byteSwapper.swap(commandString[:4]) 410 | return command.upper() in self.commandList 411 | 412 | def getNukiCommandText(self, command): 413 | return { 414 | '0001': 'Nuki_REQ', 415 | '0003': 'Nuki_PUBLIC_KEY', 416 | '0004': 'Nuki_CHALLENGE', 417 | '0005': 'Nuki_AUTH_AUTHENTICATOR', 418 | '0006': 'Nuki_AUTH_DATA', 419 | '0007': 'Nuki_AUTH_ID', 420 | '000C': 'Nuki_STATES', 421 | '001E': 'Nuki_AUTH_ID_CONFIRM', 422 | '000E': 'Nuki_STATUS', 423 | '0023': 'Nuki_LOCK_ENTRIES_REQUEST', 424 | '0024': 'Nuki_LOG_ENTRY', 425 | '0026': 'Nuki_LOG_ENTRY_COUNT', 426 | '0012': 'Nuki_ERROR', 427 | }.get(command.upper(), 'UNKNOWN') # UNKNOWN is default if command not found 428 | 429 | def parse(self, commandString): 430 | if self.isNukiCommand(commandString): 431 | command = self.byteSwapper.swap(commandString[:4]).upper() 432 | payload = commandString[4:-4] 433 | crc = self.byteSwapper.swap(commandString[-4:]) 434 | print "command = %s, payload = %s, crc = %s" % (command,payload,crc) 435 | if command == '0001': 436 | return Nuki_REQ(payload) 437 | elif command == '0003': 438 | return Nuki_PUBLIC_KEY(payload) 439 | elif command == '0004': 440 | return Nuki_CHALLENGE(payload) 441 | elif command == '0005': 442 | return Nuki_AUTH_AUTHENTICATOR(payload) 443 | elif command == '0006': 444 | return Nuki_AUTH_DATA(payload) 445 | elif command == '0007': 446 | return Nuki_AUTH_ID(payload) 447 | elif command == '000C': 448 | return Nuki_STATES(payload) 449 | elif command == '001E': 450 | return Nuki_AUTH_ID_CONFIRM(payload) 451 | elif command == '000E': 452 | return Nuki_STATUS(payload) 453 | elif command == '0023': 454 | return Nuki_LOG_ENTRIES_REQUEST(payload) 455 | elif command == '0024': 456 | return Nuki_LOG_ENTRY(payload) 457 | elif command == '0026': 458 | return Nuki_LOG_ENTRY_COUNT(payload) 459 | elif command == '0012': 460 | return Nuki_ERROR(payload) 461 | else: 462 | return "%s does not seem to be a valid Nuki command" % commandString 463 | 464 | def splitEncryptedMessages(self, msg): 465 | msgList = [] 466 | offset = 0 467 | while offset < len(msg): 468 | nonce = msg[offset:offset+48] 469 | authID = msg[offset+48:offset+56] 470 | length = int(self.byteSwapper.swap(msg[offset+56:offset+60]), 16) 471 | singleMsg = msg[offset:offset+60+(length*2)] 472 | msgList.append(singleMsg) 473 | offset = offset+60+(length*2) 474 | return msgList 475 | 476 | def decrypt(self, msg, publicKey, privateKey): 477 | #print "msg: %s" % msg 478 | nonce = msg[:48] 479 | #print "nonce: %s" % nonce 480 | authID = msg[48:56] 481 | #print "authID: %s" % authID 482 | length = int(self.byteSwapper.swap(msg[56:60]), 16) 483 | #print "length: %d" % length 484 | encrypted = nonce + msg[60:60+(length*2)] 485 | #print "encrypted: %s" % encrypted 486 | sharedKey = crypto_box_beforenm(bytes(bytearray.fromhex(publicKey)),bytes(bytearray.fromhex(privateKey))).encode("hex") 487 | box = nacl.secret.SecretBox(bytes(bytearray.fromhex(sharedKey))) 488 | decrypted = box.decrypt(bytes(bytearray.fromhex(encrypted))).encode("hex") 489 | #print "decrypted: %s" % decrypted 490 | return decrypted 491 | 492 | if __name__ == "__main__": 493 | parser = NukiCommandParser() 494 | commandString = "0600CF1B9E7801E3196E6594E76D57908EE500AAD5C33F4B6E0BBEA0DDEF82967BFC00000000004D6172632028546573742900000000000000000000000000000000000000000052AFE0A664B4E9B56DC6BD4CB718A6C9FED6BE17A7411072AA0D31537814057769F2" 495 | commandParsed = parser.parse(commandString) 496 | if parser.isNukiCommand(commandString): 497 | commandShow = commandParsed.show() 498 | print commandShow 499 | else: 500 | print commandParsed 501 | print "Done" --------------------------------------------------------------------------------