/**
* Neato Botvac Connected Series
*
* Copyright 2017,2018,2019,2020 Alex Lee Yuk Cheung
*
* Licensed under 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.
*
* VERSION HISTORY
*
* V1.0 Hubitat
* V1.1 Hubitat event update improvemnts
* V1.2 Hubitat added stop command
* V1.3 Hubitat fixes and improvements
* V1.4 Hubitat minor fixes
* V1.5 Hubitat added ability to toggle schedules
* V1.6 Hubitat improved refresh schedule method
* V1.7 Hubitat Removed Schedule toggle, added Schedule On and Off Commands
* V1.8 Hubitat Added Clear alert Command, fixes and cleanup
* V1.9 Hubitat Added Commands to Set - Power and Navigation modes
* V2.0 Hubitat Added rooms/zones child devices for D7 Vacuums
*
*/
def driverVer() { return "2.0" }
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
preferences{
def refreshRate = [:]
refreshRate << ["5 min" : "Refresh every 5 minutes"]
refreshRate << ["10 min" : "Refresh every 10 minutes"]
refreshRate << ["15 min" : "Refresh every 15 minutes"]
refreshRate << ["30 min" : "Refresh every 30 minutes"]
input("dockRefresh", "enum", title: "Refresh Interval while resting",options: refreshRate, defaultValue: "15 min", required: true )
input("runRefresh", "number", title: "Refresh Interval while running", description: "*In Seconds*", defaultValue: 30, required: true )
input( "prefPersistentMapMode", "enum", options: ["on", "off"], title: "Use Persistent Map, No-Go-Lines", description: "*Only supported on certain models*", required: false, defaultValue: on )
input(name: "offEnable", type: "bool", title: "Off = Paused by default, Enable for Return to Dock", defaultValue: false)
input(name:"clearEnable",type:"bool",title: "Enable to automatically clear alerts",required:false,defaultValue: false)
input(name:"zoneEnable",type:"bool",title: "Enable Zone Child Devices", description: "*D7 model only*",required: true, defaultValue: false)
input(name:"logInfo",type:"bool",title: "Enable Info logging",required: true,defaultValue: true)
input(name: "debugEnable", type: "bool", title: "Enable Debug Logging", defaultValue: true)
}
metadata {
definition (name: "Neato Botvac Connected Series", namespace: "alyc100", author: "Alex Lee Yuk Cheung") {
capability "Battery"
capability "Refresh"
capability "Switch"
capability "Actuator"
command "refresh"
command "clearAlert"
command "returnToDock"
command "findMe" //(Only works on D7 model)
command "start"
command "stop"
command "pause"
command "scheduleOn"
command "scheduleOff"
command "setPowerMode", [[name:"Set Power Mode", type: "ENUM",description: "Set Power Mode", constraints: ["eco", "turbo"]]]
command "setNavigationMode", [[name:"Set Navigation Mode", type: "ENUM",description: "Set Navigation Mode", constraints: ["standard", "extraCare","deep"]]]
attribute "status","string"
attribute "mode","string"
attribute "navigation","string"
attribute "zone","string"
attribute "network","string"
attribute "charging","string"
attribute "error","string"
attribute "alert","string"
attribute "schedule","string"
}
}
def installed() {
logDebug ("Installed with settings: ${settings}")
initialize()
}
def updated() {
logDebug ("Updated with settings: ${settings}")
state.DriverVersion=driverVer()
if (state.pwrMode == null){
state.pwrMode = "turbo"
}
if (state.navMode == null){
state.navMode = "standard"
}
switch(dockRefresh) {
case "5 min" :
runEvery5Minutes(refresh)
logDebug ("refresh every 5 minutes schedule")
if (logInfo) log.info "$device.label refresh every 5 minutes schedule"
break
case "10 min" :
runEvery10Minutes(refresh)
logDebug ("refresh every 10 minutes schedule")
if (logInfo) log.info "$device.label refresh every 10 minutes schedule"
break
case "15 min" :
runEvery15Minutes(refresh)
logDebug ("refresh every 15 minutes schedule")
if (logInfo) log.info "$device.label refresh every 15 minutes schedule"
break
case "30 min" :
runEvery30Minutes(refresh)
logDebug ("refresh every 30 minutes schedule")
if (logInfo) log.info "$device.label refresh every 30 minutes schedule"
break
}
if (state.model == "BotVacD7Connected"){
if (zoneEnable){
zoneAdd()
}else{
zoneRemove()
}
}
initialize()
}
def initialize() {
poll()
if(debugEnable){
runIn(1800, logsOff)
}
}
def refreshSch(){
def currentState = device.currentValue("status")
if (currentState == "paused"){
state.paused = true
}else{
state.paused = false
}
if (!state.isDocked){
logDebug ("$runRefresh second refresh active")
runIn(runRefresh,refresh)
}
}
def on() {
logDebug ("Executing 'on'")
if (state.paused){
nucleoPOST("/messages", '{"reqId":"1", "cmd":"resumeCleaning"}')
}
else{
if (prefPersistentMapMode == "off"){
catParam = 2
}else{
catParam = 4
}
if (state.pwrMode == "eco"){
modeParam = 1
}else{
modeParam = 2
}
if (state.navMode == "standard"){
navParam = 1
}
else if (state.navMode == "extraCare"){
navParam = 2
}
else if (state.navMode == "deep"){
modeParam = 2
navParam = 3
}
switch (state.houseCleaning) {
case "basic-1":
nucleoPOST("/messages", '{"reqId":"1", "cmd":"startCleaning", "params":{"category": 2, "mode": ' + modeParam + ', "modifier": 1}}')
break;
case "minimal-2":
nucleoPOST("/messages", '{"reqId":"1", "cmd":"startCleaning", "params":{"category": 2, "navigationMode": ' + navParam + '}}')
break;
default:
nucleoPOST("/messages", '{"reqId":"1", "cmd":"startCleaning", "params":{"category": ' + catParam + ', "mode": ' + modeParam + ', "navigationMode": ' + navParam + '}}')
break;
}
}
runIn(2, refresh)
}
def start() {
on()
}
def pause() {
logDebug ("Executing Pause")
nucleoPOST("/messages", '{"reqId":"1", "cmd":"pauseCleaning"}')
runIn(2, refresh)
}
def stop() {
logDebug ("Executing Stop")
nucleoPOST("/messages", '{"reqId":"1", "cmd":"stopCleaning"}')
runIn(2, refresh)
}
def off() {
if (offEnable) {
returnToDock()
}else{
pause()
}
}
def returnToDock() {
logDebug ("Executing 'return to dock'")
nucleoPOST("/messages", '{"reqId":"1", "cmd":"sendToBase"}')
sendEvent(name:"status",value:"returning to dock")
runIn(25, refresh)
}
def findMe() {
//Only works on D7 model
logDebug ("Executing 'findMe'")
nucleoPOST("/messages", '{"reqId": "1","cmd":"findMe"}')
}
def scheduleOn() {
logDebug ("Executing Schedule Enable")
nucleoPOST("/messages", '{"reqId":"1", "cmd":"enableSchedule"}')
runIn(2, refresh)
}
def scheduleOff() {
logDebug ("Executing Schedule Disable")
nucleoPOST("/messages", '{"reqId":"1", "cmd":"disableSchedule"}')
runIn(2, refresh)
}
def clearAlert(){
logDebug ("Clearing current alert")
nucleoPOST("/messages", '{"reqId":"1", "cmd":"dismissCurrentAlert"}')
runIn(2,refresh)
}
def setPowerMode(mode){
if (mode == "turbo"){
state.pwrMode = "turbo"
}
else if (mode == "eco" && state.navMode != "deep"){
state.pwrMode = "eco"
}else{
logDebug "cannot set Eco mode when navigaion mode is set to Deep"
}
}
def setNavigationMode(mode){
if (mode == "standard"){
state.navMode = "standard"
}
else if (mode == "extraCare"){
state.navMode = "extraCare"
}
else if (mode == "deep"){
state.navMode = "deep"
state.pwrMode = "turbo"
}
}
def poll() {
logDebug ("Executing 'poll'")
resp = nucleoPOST("/messages", '{"reqId":"1", "cmd":"getRobotState"}')
}
def refresh() {
logDebug ("Executing 'refresh'")
if (parent.getSecretKey(device.deviceNetworkId) == null) {
}
poll()
refreshSch()
}
def nucleoPOST(path, body) {
try {
if (debugEnable) log.debug("Beginning API POST: ${nucleoURL(path)}, ${body}")
def date = new Date().format("EEE, dd MMM yyyy HH:mm:ss z", TimeZone.getTimeZone('GMT'))
httpPostJson(uri: nucleoURL(path), body: body, headers: nucleoRequestHeaders(date, getHMACSignature(date, body)) ) {response ->
parent.logResponse(response)
def resp = (response.data)
def status = (response.status)
def result = resp
if (status != 200) {
if (result.find{ it.key == "message" }){
switch (result.message) {
case "Could not find robot_serial for specified vendor_name":
statusMsg += 'Robot serial and/or secret is not correct.\n'
break;
}
}
log.error("Unexpected result in poll(): [${resp}] ${status}")
sendEvent(name:"status",value:"error")
sendEvent(name:"network",value:"not connected")
logDebug ("Not Connected To Neato")
}
else if (result.find{ it.key == "cleaning" }){
batteryLevel = result.details.charge as String
batteryPercent = result.details.charge as Integer
logDebug ("Battery level ${batteryLevel}")
if (logInfo) log.info "$device.label Battery level ${batteryLevel}"
sendEvent(name:"battery",value: batteryLevel)
if (batteryPercent >= 95){
state.batteryFull = true
}else{
state.batteryFull = false
}
mode = result.cleaning.mode as Integer
if (mode == 1){
logDebug ("Cleaning mode is eco")
sendEvent(name:"mode",value:"eco")
}
else if (mode == 2){
logDebug ("Cleaning mode is eco")
sendEvent(name:"mode",value:"turbo")
}else{
logDebug ("Cleaning mode unknown")
if (logInfo) log.info "$device.label cleaning mode unknown"
sendEvent(name:"mode",value:"unknown")
}
navMode = result.cleaning.navigationMode as Integer
if (navMode == 1){
logDebug ("Navigation mode is standard")
sendEvent(name:"navigation",value:"standard")
}
else if (navMode == 2){
logDebug ("Navigaton mode is extraCare")
sendEvent(name:"navigation",value:"extraCare")
}
else if (navMode == 3){
logDebug ("Navigation mode is Deep")
sendEvent(name:"navigation",value:"deep")
}else{
logDebug ("Navigation mode unknown")
if (logInfo) log.info "$device.label navigation mode unknown"
sendEvent(name:"navigation",value:"unknown")
}
if (result.cleaning.boundary != null){
zone = result.cleaning.boundary.name as String
sendEvent(name:"zone",value:"$zone")
}else{
sendEvent(name:"zone",value:"Home")
}
}
if (result.find{ it.key == "action" }){
if (result.action == 4) {
state.returningToDock = true
logDebug ("returningToDock = true" )
}else{
state.returningToDock = false
logDebug ("returningToDock = false" )
}
}
if (result.find{ it.key == "availableServices" }){
state.houseCleaning = result.availableServices.houseCleaning
}
if (result.find{ it.key == "state" }){
sendEvent(name:"network",value:"connected")
//state 1 - Ready to clean,state 2 - Cleaning, state 3 - Paused, state 4 - Error
switch (result.state) {
case "1":
state.noError = true
sendEvent(name:"switch",value:"off")
if (! state.isDocked) {
sendEvent(name:"status",value:"stopped")
logDebug ("switch status should be off - Stopped")
if (logInfo) log.info "Botvac Stopped"
}
break;
case "2":
state.noError = true
if (state.returningToDock){
sendEvent(name:"status",value:"returning to dock")
sendEvent(name:"switch",value:"on")
logDebug ("switch should be on - returning to dock")
if (logInfo) log.info "$device.label Returning to Dock"
}else{
sendEvent(name:"status",value:"running")
sendEvent(name:"switch",value:"on")
logDebug ("switch should be on - running")
if (logInfo) log.info "$device.label Running"
}
break;
case "3":
state.noError = true
sendEvent(name:"status",value:"paused")
sendEvent(name:"switch",value:"on")
logDebug ("Vacuum should be paused")
if (logInfo) log.info "$device.label Paused"
break;
case "4":
state.noError = false
sendEvent(name:"status",value:"error")
logDebug ("Vacuum Error??")
if (logInfo) log.info "$device.label error"
break;
default:
sendEvent(name:"status",value:"unknown")
break;
}
}
if (result.find{ it.key == "error" }){
errorCode = result.error as String
if (errorCode == null){
logDebug ("No errors")
sendEvent(name:"error",value:"clear")
}else{
logDebug ("Error is - $errorCode")
errorMsg = "Error. " + result.error.replaceAll('_',' ').replaceAll('batt','battery').replaceAll('gen','robot was').replaceAll('hw','hardware').replaceAll('maint',' ').replaceAll('nav','navigation error, ').capitalize()
if (logInfo) log.info "$device.label error - $errorMsg"
sendEvent(name:"error",value:errorMsg)
}
}
if (result.find{ it.key == "alert" }){
alertText = result.alert as String
if (alertText == null){
logDebug ("No Alerts")
sendEvent(name:"alert",value:"clear")
}else{
logDebug ("Alert is - $alertText")
alertMsg = "Alert. " + result.alert.replaceAll('_',' ').replaceAll('maint','time for').replaceAll('nav',' ').replaceAll('sched','schedule').capitalize()
if (logInfo) log.info "$device.label error - $alertMsg"
sendEvent(name:"alert",value:alertMsg)
if (clearEnable){
runIn(5,clearAlert)
}
}
}
if (result.find{ it.key == "details" }){
docked = result.details.isDocked as String
if (docked == "true") {
logDebug ("Vacuum now Docked")
if (logInfo) log.info "$device.label Docked"
state.isDocked = true
if (state.noError){
sendEvent(name:"status",value:"docked")
}
}else{
logDebug ("Botvac Not Docked")
state.isDocked = false
}
charge = result.details.isCharging as String
logDebug ("charge status $charge")
//if (logInfo) log.info "$device.label charging $charge"
if (charge == "false"){
state.notCharging = true
}else{
state.notCharging = false
}
if (state.notCharging && state.batteryFull){
sendEvent(name:"charging",value:"fully charged")
}else{
sendEvent(name:"charging",value:result.details.isCharging as String)
}
scheduleStatus = result.details.isScheduleEnabled as String
logDebug ("Schedule Enabled - $scheduleStatus")
//if (logInfo) log.info "$device.label Schedule Enabled - $scheduleStatus"
if (scheduleStatus == "true"){
sendEvent(name:"schedule",value:"enabled")
}else{
sendEvent(name:"schedule",value:"disabled")
}
}
if (result.find{ it.key == "meta" }){
model = result.meta.modelName as String
state.model = "$model"
}
return response
}
} catch (groovyx.net.http.HttpResponseException e) {
parent.logResponse(e.response)
return e.response
}
}
/////////////////////////////Zone Devices D7 Only//////////////////////
def zoneAdd(){
def childDevice = getChildDevices()?.find {it.data.componentLabel == "zone"}
if (!childDevice) {
def resp2 = parent.beehiveGET("/users/me/robots/${device.deviceNetworkId.tokenize("|")[0]}/persistent_maps")
def mapId = resp2.data[0].id
nucleoPOST2("/messages", '{"reqId": "1", "cmd": "getMapBoundaries", "params": {"mapId": "' + mapId + '"}}')
if (debugEnable) log.debug("map ID = ${mapId}")
}else{
if (debugEnable) log.debug("Child zones already created- to add or update, remove child devices and try again")
}
}
def nucleoPOST2(path, body) {
try {
if (debugEnable) log.debug("Beginning API POST: ${nucleoURL(path)}, ${body}")
def date = new Date().format("EEE, dd MMM yyyy HH:mm:ss z", TimeZone.getTimeZone('GMT'))
httpPostJson(uri: nucleoURL(path), body: body, headers: nucleoRequestHeaders(date, getHMACSignature(date, body)) ) {response ->
parent.logResponse(response)
def resp = (response.data)
def status = (response.status)
def result = resp
if (result.find{ it.key == "data" }){
if (result.data.boundaries != null){
def rooms = [:]
result.data.boundaries.findAll { it.name }.each { rooms[it.name] = it.id }
if (debugEnable) log.debug "$rooms"
result.data.boundaries.findAll { it.name }.each {
def zoneName = "$it.name"
def zoneId = "$it.id"
log.trace "$zoneName = $zoneId"
if (zoneName != null) {
log.info("Adding Neato zone device ${zoneName}:${zoneId}")
childDevice = addChildDevice("alyc100","Neato Botvac Zone Child","${zoneId}",[name:"Neato Botvac Zone - ${zoneName}",label: "Vacuum ${zoneName}", isComponent: true, componentLabel: "zone"])
childDevice.setId("$zoneId")
childDevice.off()
if (debugEnable) "Created Zone Child - ${zoneName} with id: ${zoneId}"
}
}
}
}
return response
}
} catch (groovyx.net.http.HttpResponseException e) {
parent.logResponse(e.response)
return e.response
}
}
def zoneRemove(){
def childDevice = getChildDevices()?.find {it.data.componentLabel == "zone"}
if (childDevice) {
if (debugEnable) log.debug "Deleting Zone children"
def children = getChildDevices()
children.each {child->
deleteChildDevice(child.deviceNetworkId)
}
}else{
if (debugEnable) log.debug "No Zone children to delete"
}
}
/////////////////////////////Zone Devices D7 Only//////////////////////
def getHMACSignature(date, body) {
//request params
def robot_serial = device.deviceNetworkId
//Format date should be "Fri, 03 Apr 2015 09:12:31 GMT"
def robot_secret_key = parent.getSecretKey(device.deviceNetworkId)
// build string to be signed
def string_to_sign = "${robot_serial.toLowerCase()}\n${date}\n${body}"
// create signature with SHA256
//signature = OpenSSL::HMAC.hexdigest('sha256', robot_secret_key, string_to_sign)
try {
Mac mac = Mac.getInstance("HmacSHA256")
SecretKeySpec secretKeySpec = new SecretKeySpec(robot_secret_key.getBytes(), "HmacSHA256")
mac.init(secretKeySpec)
byte[] digest = mac.doFinal(string_to_sign.getBytes())
return digest.encodeHex()
} catch (InvalidKeyException e) {
throw new RuntimeException("Invalid key exception while converting to HMac SHA256")
}
}
Map nucleoRequestHeaders(date, HMACsignature) {
return [
'X-Date': "${date}",
'Accept': 'application/vnd.neato.nucleo.v1',
'Content-Type': 'application/*+json',
'X-Agent': '0.11.3-142',
'Authorization': "NEATOAPP ${HMACsignature}"
]
}
def nucleoURL(path = '/') { return "https://nucleo.neatocloud.com:4443/vendors/neato/robots/${device.deviceNetworkId.tokenize("|")[0]}${path}" }
void logDebug(String msg){
if (settings?.debugEnable != false){
log.debug "$msg"
}
}
def logsOff(){
log.warn "debug logging disabled..."
device.updateSetting("debugEnable", [value:"false",type:"bool"])
}