[FEATURE REQUEST] Nuki Smart Lock Integration

Hey there,

another request from me @mike.maxwell @bravenel @bobbyD . I would love to have the Nuki Smart Lock integrated. It is WiFi, but with the possibility of local control and an open API. The best part, imho, is that it can be used with the intercom buzzer of your apartment, allowing you to let yourself in, just by using the bell. In this case, there won't be a ring, but the buzzer will just be activated. Additionally, you can choose to open your door using just your app or an additional key pad, a fob or your fingerprint, based on your preference. At the bare minimum, you'll need the nuki lock and the bridge, connecting everything to your local WiFi, if you picked the standard version, or you'll need just the lock (bridge is integrated), if you select the pro version.

Here the products:

APIs:

https://developer.nuki.io/page/nuki-web-api-1-4/3

https://developer.nuki.io/page/nuki-bridge-http-api-1-13/4

General for developers:

API looks reasonably straight forward on this one, and includes the option of the lock/bridge notifying a URL on status change (lock/unlock). Would take a little effort but there are several devs on here that could make this work I believe.

1 Like

That's what I thougt, too. And for me a real selling point is, that it is the only lock with a real solution for apartments. With the other ones, as good as they might be, you'll never be keyless, as long as you don't live in a house.

Do you have any devices to test with currently? If so, here is the beginnings of an app that should at least demonstrate the capability get the token and device information from the local bridge.

Demonstration App
/*
 * Nuki Locks
 *
 *  Licensed Virtual the Apache License, Version 2.0 (the "License"); you may not use this file except
 *  in compliance with the License. You may obtain a copy of the License at:
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
 *  on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
 *  for the specific language governing permissions and limitations under the License.
 *
 *  Change History:
 *
 *    Date        Who           What
 *    ----        ---           ----
 */

static String version()	{  return '0.0.1'  }
import groovy.transform.Field
import java.net.URLEncoder
import groovy.json.JsonOutput
import groovy.json.JsonSlurper

definition (
	name: 			"Nuki Locks", 
	namespace: 		"thebearmay", 
	author: 		"Jean P. May, Jr.",
	description: 	"Provide Interface for Nuki Locks",
	category: 		"Utility",
	importUrl: "https://raw.githubusercontent.com/thebearmay/hubitat/main/apps/xxxxx.groovy",
	oauth: 			true,
    iconUrl:        "",
    iconX2Url:      ""
) 

preferences {
   page name: "mainPage"
   page name: "nukiSetup"
}

mappings {
    path("/nStatus") {
        action: [POST: "nukiStatus"]
    }
}

void installed() {
	if(debugEnabled) log.trace "installed()"
    state?.isInstalled = true
    initialize()
}

void updated(){
	if(debugEnabled) log.trace "updated()"
    if(!state?.isInstalled) { state?.isInstalled = true }
	if(debugEnabled) runIn(1800,logsOff)
}

void initialize(){
}

void logsOff(){
     app.updateSetting("debugEnabled",[value:"false",type:"bool"])
}

def mainPage(){
    dynamicPage (name: "mainPage", title: "", install: true, uninstall: true) {
      	if (app.getInstallationState() == 'COMPLETE') { 
	    	section("Main") {
                href "nukiSetup", title: "nukiSetup", required: false
     	    }
            
           section("Reset Application Name", hideable: true, hidden: true){
               input "nameOverride", "text", title: "New Name for Application", multiple: false, required: false, submitOnChange: true, defaultValue: app.getLabel()
               if(nameOverride != app.getLabel) app.updateLabel(nameOverride)
           }

	    } else {
		    section("") {
			    paragraph title: "Click Done", "Please click Done to install app before continuing"
		    }
	    }
    }
}


def nukiSetup(){
    dynamicPage (name: "nukiSetup", title: "", install: false, uninstall: false) {
        section("Nuki Setup", hideable: true, hidden: false){
            input "nukiUrl", "text", title:"<b>URL to the local bridge:</b>",submitOnChange:true
            if (nukiUrl != null) {
                input "getAuth", "button", title:"Get Nuki Token"
                paragraph "When issuing this API-call the bridge turns on its LED for 30 seconds. \nThe button of the bridge has to be pressed within this timeframe. \nOtherwise the bridge returns a negative success and no token."
                if(state.nukiToken) paragraph "Nuki Token: $state.nukiToken"
            }
            if(state.nukiToken) input "getDevices", "button", title: "Discover Devices"
        }        
    }
}

         
//Begin App Communication         
void sendRemote(command) {
    if(command != "/auth") nUri = "$nukiUrl$command?token=$state.nukiToken"
    else nUri = "$nukiUrl$command"
    
	Map requestParams =
	[
        uri:  "$nUri",
        requestContentType: 'application/json',
		contentType: 'application/json',
        body: []
	]

    if(debugEnabled) log.debug "$requestParams"
    asynchttpPost("getResp", requestParams, [cmd:"${command}"]) 
}

void getResp(resp, data) {
    try {
        if(debugEnabled) log.debug "$resp.properties - ${data['cmd']} - ${resp.getStatus()}"
        if(resp.getStatus() == 200 || resp.getStatus() == 207){
            if(resp.data) 
                state.returnString = resp.data
            
                if(data['cmd'] == "/auth")
                   storAuth(resp.data)
                else if(data['cmd'] == "/list")
                   processDevices(resp.data)
                   
            else state.returnString = "{\"value\":\"Null Data Set\", \"status\":\"${resp.getStatus()}\"}"
        } else 
            state.returnString =  "{\"status\":\"${resp.getStatus()}\"}"
    } catch (Exception ex) {
        state.returnString = ex.message
        log.error ex.message
    }
    state.lastStatus = resp.getStatus()

}

void jsonResponse(retData){
    render (contentType: 'application/json', body: JsonOutput.toJson(retData) )
}                                                                  

void nukiStatus() {
    if(debugEnabled) log.debug "Nuki Callback Received"
    jsonResponse(status: "acknowledged")
    // send lock / unlock to correct virtual device
}

// End App Communication
     

void storAuth(data) {
    def jSlurp = new JsonSlurper()
    Map resMap = (Map)jSlurp.parseText((String)data)
    state.nukiToken = resMap["token"]
    //gen access token & send callback address 
    // /callback/add?url=urlEncoded-HE-CloudAPI w/access token
}

void processDevices(data){
    def jSlurp = new JsonSlurper()
    resMap = jSlurp.parseText((String)data)
    state.devIdList = resMap["nukiId"]
    //create virtual devices to allow lock/unlock from HE and store attributes
    //deviceType = 0 or 4
    //subscribe to lock attribute
}

void childDeviceHndlr(evt) {
    // Handle lock events from childDevices 
}

void appButtonHandler(btn) {
    switch(btn) {
        case "getAuth":
            if(debugEnabled) log.debug "Get Auth"
            sendRemote("/auth")
            break
        case "getDevices":
            if(debugEnabled) log.debug "Get Auth"
            sendRemote("/list")
            break
        case "getAuth":
            if(debugEnabled) log.debug "Get Auth"
            sendRemote("/auth")
            break        
        default: 
            if(debugEnabled) log.error "Undefined button $btn pushed"
            break
    }
}

void intialize() {

}

void uninstalled(){
	
}
1 Like

Not right now, I'm waiting for my order. But did you just write that code? That's awesome! Thank you so much! :smiley:

1 Like

Had some code laying around that was similar, still needs a fair amount work before it would be fully functional, but works as a POC.

1 Like

Just ran across:

2 Likes