/** * Copyright 2025 Bloodtick Jones * * 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. * * Roborock Robot Vacuum * * Thanks to: 'copystring' and the https://www.npmjs.com/package/iobroker.roborock project * 'rovo89' https://gist.github.com/rovo89/dff47ed19fca0dfdda77503e66c2b7c7#file-test-js-L166 * https://www.home-assistant.io/integrations/roborock/ * * Author: bloodtick * Date: 2024-04-18 */ public static String version() {return "1.1.12"} import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.util.XmlSlurper import groovy.transform.CompileStatic import groovy.transform.Field import javax.crypto.Mac import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec import java.security.MessageDigest import java.util.Random import java.util.TimeZone import java.text.SimpleDateFormat // This value is stored hardcoded in librrcodec.so, encrypted by the value of "com.roborock.iotsdk.appsecret" from AndroidManifest.xml. @Field static final String salt = "TXdfu\$jyZ#TZHsg4" // Hours of possible useage for each consumable. These are probably different per model. @Field static final Map life = [ main:300, side:200, filter:150, sensor:30, highSpeed:300] metadata { definition (name: "Roborock Robot Vacuum", namespace: "bloodtick", author: "Hubitat", importUrl:"https://raw.githubusercontent.com/bloodtick/Hubitat/main/roborockRobotVacuum/roborockRobotVacuum.groovy") { capability "Actuator" capability "Battery" capability "Initialize" capability "Refresh" capability "Switch" // Special capablity to allow for Hubitat dashboarding to set commands via the Button template // Use Hubitat 'Button Controller' built in app to set commands to run. capability "PushableButton" command "zRequestEmailCode" command "zAuthorizeEmailCode", [[type:"STRING", description:"REQUIRED: Enter CODE provided by Roborock"]] command "appClean" command "appDock" command "appPause" command "appRoomClean", [[name: "Room IDs*", type: "STRING", description: "Accepts comma or space delmited Room IDs"], [name: "MopWater", type: "ENUM", description: "Set the room water mopping params. Default is no change of current setting. Not required.", constraints: mopWaterModeCodes.values().collect{ it.toUpperCase() }]] command "appRoomResume" command "appScene", [[name: "Scene ID*", type: "STRING", description: "Accepts single Scene ID"]] command "execute", [[name: "command*", type: "STRING", description: "The command to send device via mqtt"],[name: "params", type: "JSON_OBJECT", description: "Command parameters in JSON object"]] command "selectDevice" attribute "dustCollection", "enum", ["off","on"] attribute "dockError", "enum", dockErrorCodes.values().collect{ it.toLowerCase() } attribute "name", "string" attribute "rooms", "JSON_OBJECT" attribute "scenes", "JSON_OBJECT" attribute "state", "enum", stateCodes.values().collect{ it.toLowerCase() } attribute "error", "enum", errorCodes.values().collect{ it.toLowerCase() } attribute "fanPower", "enum", fanPowerCodes.values().collect{ it.toLowerCase() } attribute "cleanTime", "number" attribute "cleanArea", "number" attribute "cleanPercent", "number" attribute "remainingFilter", "number" attribute "remainingMainBrush", "number" attribute "remainingSensors", "number" attribute "remainingSideBrush", "number" attribute "remainingHighSpeedMaintBrush", "number" attribute "locating", "enum", ["true","false"] attribute "mopMode", "enum", mopModeCodes.values().collect{ it.toLowerCase() } attribute "mopWaterMode", "enum", mopWaterModeCodes.values().collect{ it.toLowerCase() } attribute "healthStatus", "enum", ["offline", "online"] } } preferences { input(name:"username", type:"string", title: "Roborock Username:", required: true, width:4) input(name:"password", type:"password", title: "Roborock Password:", required: true, width:4) input(name:"regionUri", type:"enum", title: "Account Region:", options:["https://usiot.roborock.com":"US", "https://euiot.roborock.com":"EU", "https://cniot.roborock.com":"CN", "https://ruiot.roborock.com":"RU"], defaultValue: "https://usiot.roborock.com", required: true, width:4) input(name:"allowLogin", type:"bool", title: "Authorize Account User Login:", defaultValue: true, width:4, description: "Enable to re/attempt intial login with username and password.") input(name:"autoLogin", type: "enum", title: "Auto Authorize Account User Login:", options: [ "manual":"Manual Only", "900":"15 Minutes", "1800":"30 Minutes", "3600":"1 Hour", "10800":"3 Hours"], defaultValue: "1800", description: "If device goes offline re/attempt intial login with username and password.", required: true) input(name:"areaUnit", type:"enum", title: "Device Area Unit:", options:["0":"Square Foot (ft²)", "1":"Square Meter (m²)"], defaultValue: "0", required: true, width:4) input(name:"numberOfButtons", type: "number", title: "Set Number of Buttons:", range: "1...", defaultValue: 1, required: true, width:4) input(name:"cleanAttributeDisable", type:"bool", title: "Disable chatty cleanPercent, cleanArea, cleanPercent events:", defaultValue: false, width:4) input(name:"deviceInfoDisable", type:"bool", title: "Disable Info logging:", defaultValue: false, width:4) input(name:"deviceDebugEnable", type:"bool", title: "Enable Debug logging:", defaultValue: false, width:4) //input(name:"deviceTraceEnable", type:"bool", title: "Enable Trace logging:", defaultValue: false, width:4) } def logsOff() { device.updateSetting("deviceDebugEnable",[value:'false',type:"bool"]) device.updateSetting("deviceTraceEnable",[value:'false',type:"bool"]) logInfo "disabling debug logs" } Boolean autoLogsOff() { if ((Boolean)settings.deviceDebugEnable || (Boolean)settings.deviceTraceEnable) runIn(1800, "logsOff"); else unschedule('logsOff');} def installed() { initialize() } def updated() { initialize() } def initialize() { unschedule() autoLogsOff() sendEvent(name:"numberOfButtons", value: (settings?.numberOfButtons)?:1) if(settings?.allowLogin && settings?.username && settings?.password) { logInfo "executing 'initialize()' allowLogin" disconnect() // blow away all state information state?.keySet()?.collect()?.each{ state.remove(it) } state.sequence = (new Random().nextInt(2000) + 1) if(state?.restore) state.duid = state.restore clearAttributes() Map login = login() if(login?.msg=="success") { device.updateSetting("allowLogin",[value:'false',type:"bool"]) runIn(1, "getHomeDetail") //runs getHomeData()->getHomeDataCallback() async serial return } else { device.updateSetting("allowLogin",[value:'false',type:"bool"]) logWarn "login failed with username:'$username' password:'$password' msg:${login?.msg}" if(login?.code!=null && login.code.toInteger() == 2031) { processEvent("state", 501) } else { processEvent("state", 500) } } } else if(state?.login) { disconnect() runIn(1, "getHomeData") //runs getHomeDataCallback() async serial } } def zRequestEmailCode() { if(state.sendEmailCodeTimestamp) { Integer timeout = 60 * 1000 Long last = state.sendEmailCodeTimestamp as Long Long remaining = (last + timeout) - now() if(remaining > 0) { logWarn "need to wait ${(remaining / 1000).toInteger()} seconds before requesting email code again" return } } state.sendEmailCodeTimestamp = now() logInfo "executing 'zRequestEmailCode()'" Map sendEmailCode = sendEmailCode() if(sendEmailCode.msg=="success") { processEvent("state", 502) } else { logWarn "request email code failed msg:'${sendEmailCode?.msg}'" processEvent("state", 504) } } def zAuthorizeEmailCode(String pin) { logInfo "executing 'zAuthorizeEmailCode($pin)'" if(!pin || !state?.sendEmailCodeTimestamp || now() > ((state.sendEmailCodeTimestamp as Long) + 15*60*1000)) { // only good for 15 min logWarn "authorize code failed pin:'$pin' timestamp:'${state?.sendEmailCodeTimestamp ?: "expired"}'" return } Map loginWithCode = loginWithCode(pin) if(loginWithCode?.msg=="success") { disconnect() processEvent("state", 503) // good runIn(1, "getHomeDetail") //runs getHomeData()->getHomeDataCallback() async serial state.remove("sendEmailCodeTimestamp") } else { logWarn "authorize email code failed pin:'$pin' msg:'${loginWithCode?.msg}'" processEvent("error_code", 257) processEvent("state", 500) } } def push(buttonNumber) { sendEvent(name: "pushed", value: buttonNumber, isStateChange: true) } def on() { appClean(); processEvent("switch","on") } def off() { appDock(); processEvent("switch","off") } def appClean() { execute("app_start") } def appDock() { execute("app_charge") } def appPause() { execute("app_pause") } def appRoomResume() { execute("resume_segment_clean") } def appRoomClean(String rooms, String mopWater=mopWaterModeCodes[0]) { rooms = rooms.replaceAll(" +", ',') if(mopWater?.toUpperCase()!=mopWaterModeCodes[0].toUpperCase()) { Integer mopWaterCode = ( mopWaterModeCodes.find { it.value.toUpperCase() == mopWater?.toUpperCase() }?.key ) execute("set_water_box_custom_mode","[$mopWaterCode]") execute("get_water_box_custom_mode") } execute("app_segment_clean","[$rooms]") } def appScene(String sceneId) { setDeviceScene(sceneId) } def selectDevice() { String deviceId = findNextDevice( state?.duid ) if(state?.duid != deviceId) { state?.duid = deviceId clearAttributes() initialize() } else { logInfo "device id is ${getDeviceId()}" } } void clearAttributes() { // blow away all attribute information. not sure if this 'is the way' but it works. device.currentStates?.collect{ ((new groovy.json.JsonSlurper().parseText( groovy.json.JsonOutput.toJson(it) ))?.name) }?.each{ device.deleteCurrentState(it) } } void getHomeDataCallback() { logDebug "executing 'getHomeDataCallback()' ${getHomeDataResult()}" logDebug "device id is ${getDeviceId()}" Boolean deviceOnline = !!(getHomeDataResult()?.devices?.find{ it.duid?.toString() == getDeviceId() }?.online) //processEvent("wifi", (deviceOnline ? "online" : "offline")) if(!deviceOnline) { //logWarn "wifi is offline" processEvent("error_code", 256) setHealthStatusEvent(false) qClear() unschedule() runIn(15*60,"getHomeData") return } if( !interfaces.mqtt.isConnected() ) { runIn(3, "connect") } updateHomeData() } void updateHomeData() { logDebug "executing 'updateHomeData()'" execute("get_room_mapping") if(device.currentValue("switch")!="on") execute("get_consumable") String name = getHomeDataResult()?.devices?.find{ it.duid?.toString() == getDeviceId() }?.name ?: "unknown" processEvent("name", name) } @Field volatile static Map g_mLastRefreshTime = [:] def refresh(Map data=[type:1]) { logDebug "executing 'refresh($data)'" execute("get_prop", """["get_status"]""") if(device.currentValue("switch")=="on") execute("get_consumable") if(g_mLastRefreshTime[device.getIdAsLong()] == null) g_mLastRefreshTime[device.getIdAsLong()] = now()-120000 if(data?.type==1 && (now() - g_mLastRefreshTime[device.getIdAsLong()]) > 120000) { getHomeData() g_mLastRefreshTime[device.getIdAsLong()] = now() } } def execute(String command, String args=null) { // I have no idea if this conversion works for everything. It works for somethings... ;) def param = args ? convertNumbers((new JsonSlurper().parseText(args))) : [] // reduce info logging on these cyclic checks Closure logFunction = [ "get_prop", "get_room_mapping", "get_consumable" ].find{ it == command } ? this.&logDebug : this.&logInfo logFunction( "executing execute(command:$command, param:$param)" ) Integer id = (Integer)(state.sequence++ & 0xFFFFFFFF) qPush([duid: getDeviceId(), command: command, param: param, id:id]) if(qSize()<=1) executeQueue() } void executeQueue() { if(!qIsEmpty() && interfaces.mqtt.isConnected()) { Map cmd = qPeek() runIn(15, "watchdog") // unscheduled in processMsg() publish(cmd.duid, cmd.command, cmd.param, cmd.id) } else if(!qIsEmpty() && !interfaces.mqtt.isConnected()) { logInfo "scheduling 'connect()' in 'executeQueue()'" runIn(1, "connect") } else { unschedule('watchdog') } } void watchdog() { if(qIsEmpty()) return logInfo "executing 'watchdog()' on queue:${qPeek()}" disconnect() runIn(1, "getHomeData") } void scheduleRefresh(Integer delay=5) { logDebug "executing 'scheduleRefresh($delay)'" runIn(delay, "refresh", [data: [type:2]]) } void disconnect() { logInfo "executing 'disconnect()'" unsubscribe() interfaces.mqtt.disconnect() runIn(10, "setHealthStatusEvent") // false } void connect() { logDebug "executing 'connect()'" Map rriot = getLoginData()?.rriot String mqttUser = md5hex(rriot.u + ':' + rriot.k).substring(2, 10) String mqttPassword = md5hex(rriot.s + ':' + rriot.k).substring(16) logInfo "connecting mqttUser:$mqttUser to $rriot.r.m" try { interfaces.mqtt.connect(rriot.r.m, "${device.deviceNetworkId}", mqttUser, mqttPassword, byteInterface:true) state.remove('restore') logDebug "connected successfully" } catch (org.eclipse.paho.client.mqttv3.MqttSecurityException e) { // what i need to catch: org.eclipse.paho.client.mqttv3.MqttSecurityException: Not authorized to connect (method connect) // what i can fake: org.eclipse.paho.client.mqttv3.MqttSecurityException: Bad user name or password (method connect) logError "mqtt security exception: '${e.message}'" if(settings?.autoLogin && settings.autoLogin!="manual") { processEvent("error_code", 257) logInfo "auto scheduling 'initialize' in ${settings.autoLogin} seconds" unschedule() device.updateSetting("allowLogin",[value:'true',type:"bool"]) state.restore = state.duid runIn(settings.autoLogin.toInteger(), "initialize") } } catch (Exception e) { logError "MQTT Connection Exception: ${e.message}" } } def mqttClientStatus(String message) { logInfo "executing 'mqttClientStatus($message)'" if(message.toLowerCase().contains("connection succeeded")) { runIn(1, "subscribe") } else { disconnect() runIn(60*10, "connect") } } void subscribe() { logDebug "executing 'subscribe()'" if(!interfaces.mqtt.isConnected()) return Map rriot = getLoginData()?.rriot String mqttUser = md5hex(rriot.u + ':' + rriot.k).substring(2, 10); String mqttPassword = md5hex(rriot.s + ':' + rriot.k).substring(16); String topic = "rr/m/o/${rriot.u}/${mqttUser}/#" logInfo "subscribe topic:$topic" interfaces.mqtt.subscribe(topic) runEvery30Minutes(refresh) scheduleRefresh() updateHomeData() executeQueue() } void unsubscribe() { logDebug "executing 'unsubscribe()'" if(!interfaces.mqtt.isConnected()) return Map rriot = getLoginData()?.rriot if(rriot) { String mqttUser = md5hex(rriot.u + ':' + rriot.k).substring(2, 10); String mqttPassword = md5hex(rriot.s + ':' + rriot.k).substring(16); String topic = "rr/m/o/${rriot.u}/${mqttUser}/#" logInfo "unsubscribe topic:$topic" interfaces.mqtt.unsubscribe(topic) } } void sendEventX(Map x) { if(x?.value!=null && !x?.eventDisable && (device.currentValue(x?.name).toString() != x?.value.toString() || x?.isStateChange)) { if(x?.descriptionText) { if(x?.logLevel=="warn") logWarn (x?.descriptionText); else logInfo (x?.descriptionText); } sendEvent(name: x?.name, value: x?.value, unit: x?.unit, descriptionText: x?.descriptionText, isStateChange: (x?.isStateChange ?: false)) } } void processEvent(String name, def value) { logTrace "executing 'processEvent($name, $value)'" String descriptionText = null switch(name) { case "get_water_box_custom_mode": String valueEnum = mopWaterModeCodes[value?.toInteger()]?.toLowerCase() ?: value sendEventX(name: "mopWaterMode", value: valueEnum, descriptionText: "mop water mode is $valueEnum ($value)") break case "switch": sendEventX(name: "switch", value: value, descriptionText: "switch is $value") break case "name": sendEventX(name: "name", value: value, descriptionText: "name set to $value") break case "healthStatus": sendEventX(name: "healthStatus", value: value, descriptionText: "healthStatus set to $value", logLevel:(value=="online"?"info":"warn")) break case "wifi": //sendEventX(name: "wifi", value: value, descriptionText: "wifi set to $value") break case "rooms": sendEventX(name: "rooms", value: JsonOutput.toJson(value), descriptionText: "rooms set to $value") break case "scenes": sendEventX(name: "scenes", value: JsonOutput.toJson(value), descriptionText: "scenes set to $value") break case "rpc_request": break case "rpc_response": break case "error_code": String valueEnum = errorCodes[value?.toInteger()]?.toLowerCase() ?: value sendEventX(name: "error", value: valueEnum, descriptionText: "error is $valueEnum ($value)", logLevel:(value==0?"info":"warn")) break case "state": String valueEnum = stateCodes[value?.toInteger()]?.toLowerCase() ?: value sendEventX(name: "state", value: valueEnum, descriptionText: "state is $valueEnum ($value)") break case "battery": sendEventX(name: "battery", value: value.toInteger(), unit: "%", descriptionText: "battery level is $value%") break case "fan_power": String valueEnum = fanPowerCodes[value?.toInteger()]?.toLowerCase() ?: value sendEventX(name: "fanPower", value: valueEnum, descriptionText: "fan power is $valueEnum ($value)") break case "water_box_mode": break case "main_brush_life": break case "main_brush_work_time": Integer percentAvail = Math.max(0, (100 - Math.floor((value.toInteger() / (life.main * 60 * 60)) * 100).toInteger())) sendEventX(name: "remainingMainBrush", value: percentAvail, unit: "%", descriptionText: "main brush time remaining is $percentAvail%") break case "side_brush_life": break case "side_brush_work_time": Integer percentAvail = Math.max(0, (100 - Math.floor((value.toInteger() / (life.side * 60 * 60)) * 100).toInteger())) sendEventX(name: "remainingSideBrush", value: percentAvail, unit: "%", descriptionText: "side brush time remaining is $percentAvail%") break case "cleaning_brush_work_times": Integer percentAvail = Math.max(0, (100 - Math.floor((value.toInteger() / life.highSpeed) * 100).toInteger())) sendEventX(name: "remainingHighSpeedMaintBrush", value: percentAvail, unit: "%", descriptionText: "high-speed maintenance brush remaining life is $percentAvail%") break case "filter_life": case "filter_work_time": case "additional_props": case "task_complete": case "task_cancel_low_power": case "task_cancel_in_motion": case "charge_status": case "drying_status": break case "sensor_dirty_time": Integer percentAvail = Math.max(0, (100 - Math.floor((value.toInteger() / (life.sensor * 60 * 60)) * 100).toInteger())) sendEventX(name: "remainingSensors", value: percentAvail, unit: "%", descriptionText: "sensor time remaining is $percentAvail%") break case "filter_element_work_time": case "dust_collection_work_times": case "msg_ver": case "msg_seq": break case "clean_time": Integer totalMinutes = Math.ceil(value.toInteger()/60).toInteger() sendEventX(name: "cleanTime", value: totalMinutes, unit: "min", descriptionText: "clean time is $totalMinutes ${totalMinutes==1?"minute":"minutes"}", eventDisable: cleanAttributeDisable) break case "clean_area": String unit = (areaUnit==null || areaUnit=="0") ? "ft²" : "m²" Integer area = (unit=="ft²") ? value.toInteger() / 92903.04 : value.toInteger() / 1000000 sendEventX(name: "cleanArea", value: area, unit: unit, descriptionText: "clean area is $area $unit", eventDisable: cleanAttributeDisable) break case "map_present": case "in_cleaning": case "in_returning": case "in_fresh_state": case "lab_status": case "water_box_status": case "dnd_enabled": case "map_status": break case "is_locating": String locatingString = (value==0 ? "false" : "true") sendEventX(name: "locating", value: locatingString, descriptionText: "locating value is $locatingString ($value)") break case "lock_status": case "water_box_carriage_status": case "mop_forbidden_enable": case "camera_status": case "is_exploring": case "adbumper_status": case "water_shortage_status": case "dock_type": break case "dust_collection_status": String dustCollectionString = (value==0 ? "off" : "on") sendEventX(name: "dustCollection", value: dustCollectionString, descriptionText: "dust collection is $dustCollectionString ($value)") break case "auto_dust_collection": case "avoid_count": break case "mop_mode": String valueEnum = mopModeCodes[value?.toInteger()]?.toLowerCase() ?: value sendEventX(name: "mopMode", value: valueEnum, descriptionText: "mop mode is $valueEnum ($value)") break case "debug_mode": case "collision_avoid_status": case "switch_map_mode": break case "dock_error_status": String valueEnum = dockErrorCodes[value?.toInteger()]?.toLowerCase() ?: value sendEventX(name: "dockError", value: valueEnum, descriptionText: "dock error is $valueEnum ($value)", logLevel:(value==0?"info":"warn")) break case "unsave_map_reason": case "unsave_map_flag": break case "clean_percent": sendEventX(name: "cleanPercent", value: value.toInteger(), unit: "%", descriptionText: "percent completed is $value%", eventDisable: cleanAttributeDisable) break case "rss": case "dss": case "events": case "switch_status": case "distance_off": case "home_sec_status": case "home_sec_enable_password": break case "strainer_work_times": // start reported by Q Revo Integer percentAvail = Math.max(0, (100 - Math.floor((value.toInteger() / life.filter) * 100).toInteger())) sendEventX(name: "remainingFilter", value: percentAvail, unit: "%", descriptionText: "filter life remaining is $percentAvail%") break case "wash_status": case "wash_ready": case "wash_phase": case "rdt": case "last_clean_t": case "kct": case "in_warmup": case "dry_status": case "corner_clean_mode": case "common_status": case "back_type": case "replenish_mode": case "repeat": break default: if(settings?.deviceDebugEnable) logWarn "did not process name:$name with value:$value" } if(descriptionText) logInfo descriptionText } void processMsg(Map message) { logDebug "executing 'processMsg($message)'" message?.dps?.each { key,value -> // look up id and find the 'code' that was mapped in the home data. duid is used find the productID. Map home = getHomeDataResult() String duid = getDeviceId() String productId = home?.devices?.find{ it.duid?.toString() == duid?.toString() }?.productId String code = home?.products?.find{ it.id == productId }?.schema?.find { it.id?.toString() == key?.toString() }?.code //String code = home?.products?.find{ it.id == productId }?.schema?.find { it.id == key }?.code if(code=="rpc_response") { // we have good connection to device since we got a message back from it. setHealthStatusEvent(true) def jsonValue = null try { jsonValue = (new JsonSlurper()).parseText( value ) } catch(e) { logWarn "message not json: key:$key value:$value message:$message" } if(qPeek()?.id?.toInteger() != jsonValue?.id?.toInteger()) { if(settings?.deviceDebugEnable) logWarn "message unknown: $jsonValue" return } // lets get our command that sent this request and we can start the queue up again. Map cmd = qPop() executeQueue() if((cmd?.command=="get_prop" && cmd?.param==["get_status"]) || cmd?.command=="get_consumable") { logDebug "command '$cmd.command' was accepted" jsonValue?.result?.each{ result -> if(cmd?.param==["get_status"]) { result.switch=((result?.in_cleaning ?: 0).toInteger() != 0 || (result?.is_locating ?: 0).toInteger() != 0 || (result?.is_exploring ?: 0).toInteger() != 0) ? "on" : "off" if(result?.battery?.toInteger()==100 && result?.state?.toInteger()==8) result.state=100 if(result?.clean_percent?.toInteger()==0 && result?.clean_area?.toInteger()>1) result.clean_percent=100 if(!stateDoNotRefreshCodes.contains(result.state)) { scheduleRefresh(60) } // some units don't send real time dps events } logDebug "processing $result" result?.each{ c,v -> processEvent(c,v) } } } else if(cmd?.command=="get_room_mapping") { logDebug "command '$cmd.command' was accepted" setRoomsValue(jsonValue) } else if(cmd?.command=="get_water_box_custom_mode" && (jsonValue?.result?.water_box_mode)) { logDebug "command '$cmd.command' was accepted" processEvent(cmd?.command, jsonValue.result.water_box_mode) } else if(jsonValue?.result==["ok"] || jsonValue?.result==["OK"]) { logInfo "command '$cmd.command' was accepted" scheduleRefresh() } else { logWarn "rpc_response not handled: command:$cmd result:$jsonValue" } } else if(code!=null && value!=null) { processEvent(code,value) scheduleRefresh() } else { // this should never happen. if it does, clear the queue and wait for the normal 30min refresh to try again. logError "message not handled: key:$key value:$value" qClear() } } } void setRoomsValue(Map get_room_mapping) { logDebug "executing 'setRoomsValue()'" Map roomsMap = getHomeDataResult()?.rooms?.collectEntries { [(it.id.toString()): it.name] } if(roomsMap && get_room_mapping) { Map rooms = get_room_mapping?.result.collectEntries { mapping -> String roomId = mapping[1].toString() String roomName = roomsMap[roomId] return [(mapping[0].toString()):roomName] } processEvent("rooms", rooms?.sort()) } } void setHealthStatusEvent(Boolean mqttClientStatus=false) { unschedule('setHealthStatusEvent') Boolean deviceOnline = getHomeDataResult()?.devices?.find{ it.duid?.toString() == getDeviceId() }?.online String healthStatus = mqttClientStatus && deviceOnline ? "online" : "offline" processEvent("healthStatus", healthStatus) } def parse(String message) { logDebug "executing 'parse()'" Map mqttMessage = interfaces.mqtt.parseMessage(message) parse( mqttMessage.topic, mqttMessage.payload.decodeHex() ) } def parse(String topic, byte[] message) { String deviceId = topic.split('/')[-1] if(deviceId!=state.duid) { logDebug "parse message rejected: I am ${state.duid} and this was for $deviceId" return } String localKey = getLocalKey(deviceId) logDebug "parse deviceId:$deviceId, localKey:$localKey, topic:$topic" // .endianess('big') // .string('version', {length: 3}) // .uint32('seq') // .uint32('random') // .uint32('timestamp') // .uint16('protocol') // .uint16('payloadLen') // .buffer('payload', {length: 'payloadLen'}) // .uint32('crc32'); // Extract version as string String version = bytesToString(message, 0, 3) // Do some checks if (version!="1.0") {// && version!="A01") { logWarn "parse was not version as expected:$version, Message: ${message.encodeHex()}" return } Integer crc32 = CRC32(message, message.length - 4) Integer expectedCrc32 = readInt32BE(message, message.length - 4) if (crc32 != expectedCrc32) { logWarn "parse was not crc32:${(crc32 & 0xFFFFFFFFL)} as expected:${(expectedCrc32 & 0xFFFFFFFFL)}, Message: ${message.encodeHex()}" return } Integer sequence = readInt32BE(message, 3) Integer random = readInt32BE(message, 7) Integer timestamp = readInt32BE(message, 11) Integer protocol = readInt16BE(message, 15) if(protocol!=102) return // WE DONT HANDLE IMAGES YET Integer payloadLen = readInt16BE(message, 17) byte[] payload = message[19..(19+payloadLen-1)] logTrace "payloadLen:$payloadLen, payload:${payload.length}, byte0:${ String.format("%02x", payload[0] & 0xFF) }" logTrace "payload: ${payload.encodeHex()}" logDebug "parsed message deviceId:$deviceId, version:${version}, sequence:${sequence}, random:${random}, timestamp:${timestamp}, protocol:${protocol}, payloadLen:${payloadLen}, crc32:${Integer.toHexString(crc32)}" String key = encodeTimestamp(timestamp) + localKey + salt byte[] result = decrypt(payload, key) Map jsonObject = [:] if(protocol==102) { try { jsonObject = (new JsonSlurper()).parseText( new String(result, "UTF-8") ) } catch(e) { logWarn "payload was not json. protocol:$protocol, length:${result.length}" } } else { logDebug "payload protocol:$protocol, length:${result.length}" } if(!jsonObject.isEmpty()) { processMsg( jsonObject ) } } Integer publish(String deviceId, method, params, Integer id) { logDebug "executing 'publish($deviceId, $method, $params)'" Integer timestamp = (Integer)(now() / 1000) Integer protocol = 101 Map inner = [id:id, method:method, params:params] String payload = JsonOutput.toJson( [t:timestamp, dps:["$protocol": JsonOutput.toJson(inner)]] ) byte[] message = build(deviceId, protocol, timestamp, payload.getBytes("UTF-8")) Map rriot = getLoginData()?.rriot String mqttUser = md5hex(rriot.u + ':' + rriot.k).substring(2, 10); String topic = "rr/m/i/${rriot.u}/${mqttUser}/${deviceId}" logDebug "publishing topic:'$topic'" interfaces.mqtt.publish(topic, message.encodeHex().toString()) return requestId } byte[] build(String deviceId, Integer protocol, Integer timestamp, byte[] payload) { String localKey = getLocalKey(deviceId) String key = encodeTimestamp(timestamp) + localKey + salt byte[] encrypted = encrypt(payload, key) Random random = new Random() Integer randomInt = random.nextInt(900000) + 100000 int totalLength = 23 + encrypted.length byte[] msg = new byte[totalLength] // Writing fixed string '1.0' msg[0] = 49 // ASCII for '1' msg[1] = 46 // ASCII for '.' msg[2] = 48 // ASCII for '0' writeInt32BE(msg, (Integer)(state.sequence & 0xFFFFFFFF), 3) writeInt32BE(msg, (Integer)(randomInt & 0xFFFFFFFF), 7) writeInt32BE(msg, timestamp, 11) writeInt16BE(msg, protocol, 15) writeInt16BE(msg, encrypted.length, 17) // Manually copying encrypted data into msg for (Integer i = 0; i < encrypted.length; i++) { msg[19 + i] = encrypted[i] } Integer crc32 = CRC32(msg, msg.length - 4) writeInt32BE(msg, crc32, msg.length - 4) return msg } byte[] decrypt(byte[] payload, String key) { byte[] aesKeyBytes = md5bin(key); Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding ") SecretKeySpec keySpec = new SecretKeySpec(aesKeyBytes, "AES") cipher.init(Cipher.DECRYPT_MODE, keySpec) return cipher.doFinal(payload) } byte[] encrypt(byte[] payload, String key) { byte[] aesKeyBytes = md5bin(key) Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding") SecretKeySpec keySpec = new SecretKeySpec(aesKeyBytes, "AES") cipher.init(Cipher.ENCRYPT_MODE, keySpec) return cipher.doFinal(payload) } Integer CRC32(bytes, length) { def crc = 0xFFFFFFFF for (int i = 0; i < length; i++) { def b = bytes[i] & 0xFF // Make sure the byte is treated as unsigned crc = crc ^ b for (int j = 7; j >= 0; j--) { def mask = -(crc & 1) crc = (crc >>> 1) ^ (0xEDB88320 & mask) // Use unsigned right shift } } return (crc ^ 0xFFFFFFFFL) } String bytesToString(byte[] data, Integer start, Integer length) { return (new String( (byte[])(data[start..> 24) & 0xFF) msg[start + 1] = (byte) ((value >> 16) & 0xFF) msg[start + 2] = (byte) ((value >> 8) & 0xFF) msg[start + 3] = (byte) (value & 0xFF) } void writeInt16BE(byte[] msg, Integer value, Integer start) { msg[start + 0] = (byte) ((value >> 8) & 0xFF) msg[start + 1] = (byte) (value & 0xFF) } byte[] md5bin(String input) { MessageDigest md = MessageDigest.getInstance("MD5") return md.digest(input.getBytes("UTF-8")) } String md5hex(String input) { MessageDigest md = MessageDigest.getInstance("MD5") return md.digest(input.getBytes("UTF-8")).encodeHex() } String datetimestring() { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") return sdf.format(new Date()) } String encodeTimestamp(int timestamp) { // Convert the timestamp to a hexadecimal string and pad it to ensure it's at least 8 characters String hex = new BigInteger(Long.toString(timestamp)).toString(16).padLeft(8, '0') List hexChars = hex.toList() // Define the order in which to rearrange the hexadecimal characters int[] order = [5, 6, 3, 7, 1, 2, 0, 4] String result = order.collect { hexChars[it] }.join('') return result } // Helper method to check if a string is numeric String.metaClass.isNumber = { delegate ==~ /-?\d+(\.\d+)?/ } def convertNumbers(element) { if (element instanceof List) { // Element is a List; recursively convert each item in the list return element.collect { convertNumbers(it) } } else if (element instanceof Map) { // Element is a Map; recursively convert each value in the map return element.collectEntries { key, value -> [(key): convertNumbers(value)] } } else if (element instanceof String) { // Element is a String; attempt to convert to a number if possible if (element.isNumber()) { return element.contains('.') ? element.toFloat() : element.toInteger() } else { // Keep as String return element } } else { // For all other types, return the element as is return element } } String getHawkAuthentication(String id, String secret, String key, String path) { Integer timestamp = now() / 1000 String nonce = UUID.randomUUID().toString().replaceAll('-', '').take(8) String prestr = "$id:$secret:${nonce}:${timestamp}:${md5hex(path)}::" Mac mac = Mac.getInstance("HmacSHA256") SecretKeySpec secretKeySpec = new SecretKeySpec(key?.getBytes("UTF-8"), "HmacSHA256") mac.init(secretKeySpec) byte[] macBytes = mac.doFinal(prestr.getBytes("UTF-8")) String macString = macBytes.encodeBase64().toString() return "Hawk id=\"${id}\", s=\"${secret}\", ts=\"${timestamp}\", nonce=\"${nonce}\", mac=\"${macString}\"" } String generateHash(String username) { MessageDigest md = MessageDigest.getInstance("MD5") md.update(username.bytes) md.update(device.deviceNetworkId.bytes) byte[] finalHash = md.digest() return finalHash.encodeBase64().toString() } String getBaseURL() { String uri = settings.regionUri String path = "/api/v1/getUrlByEmail" String queryString = "email=${URLEncoder.encode(settings.username, 'UTF-8')}" String response = null httpPostJson(uri:uri, path:path, queryString:queryString) { resp -> if(resp.status == 200) { response = resp.data?.data?.url if(response && response!=settings.regionUri) { logWarn "found username:'${settings.username}' base url:'$response'" state.base = response } } else { logWarn "'getBaseURL()' failure. Status code:${response.getStatus()}" } } return response } Map login() { String uri = getBaseURL() ?: settings.regionUri String path = "/api/v1/login" String queryString = "username=${URLEncoder.encode(settings.username, 'UTF-8')}&" + "password=${URLEncoder.encode(settings.password, 'UTF-8')}&" + "needtwostepauth=${URLEncoder.encode('false', 'UTF-8')}" // Hash the username with MD5 and encode it to Base64 for the client ID header String headerClientId = generateHash(settings.username) Map headers = ['header_clientid':headerClientId] Map response = [:] httpPostJson(uri:uri, path:path, queryString:queryString, headers:headers) { resp -> if(resp.status == 200) { response = resp.data logDebug "login results (*** DO NOT SHARE ***): $response" if(resp.data?.msg != "success") { logWarn "driver only supports Roborock (not Xiaomi) integrations"; return response; } storeJsonState( "login", datetimestring(), resp.data ) } else { logWarn "'login()' failure. Status code:${response.getStatus()}" } } g_mGetLoginData[device.getIdAsLong()]?.clear() g_mGetLoginData[device.getIdAsLong()] = null return response } Map sendEmailCode() { String uri = getBaseURL() ?: settings.regionUri String path = "/api/v1/sendEmailCode" String queryString = ("username=" + URLEncoder.encode(settings.username, "UTF-8") + "&type=auth").toString() // Hash the username with MD5 and encode it to Base64 for the client ID header String headerClientId = generateHash(settings.username) Map headers = ['header_clientid':headerClientId] Map response = [:] try { httpPostJson(uri: uri, path: path, queryString: queryString, headers: headers) { resp -> if(resp.status == 200) { response = resp.data } else { logWarn "'sendEmailCode()' failure. Status code:${response.getStatus()}" } } } catch (Exception e) { logError "sendEmailCode() error: ${e}" } return response } Map loginWithCode(String verifyCode) { String uri = getBaseURL() ?: settings.regionUri String path = "/api/v1/loginWithCode" String queryString = ("username=" + URLEncoder.encode(settings.username, "UTF-8") + "&verifycode=" + URLEncoder.encode(verifyCode?.toString(), "UTF-8") + "&verifycodetype=AUTH_EMAIL_CODE").toString() // Hash the username with MD5 and encode it to Base64 for the client ID header String headerClientId = generateHash(settings.username) Map headers = ['header_clientid':headerClientId] Map response = [:] try { httpPostJson(uri: uri, path: path, queryString: queryString, headers: headers) { resp -> response = resp.data logDebug "loginWithCode results (*** DO NOT SHARE ***): $response" if(resp.data?.msg == "success") storeJsonState( "login", datetimestring(), resp.data ) } } catch (Exception e) { logWarn "loginWithCode() error: ${e}" } g_mGetLoginData[device.getIdAsLong()]?.clear() g_mGetLoginData[device.getIdAsLong()] = null return response } void getHomeDetail() { Map params = [ uri: state?.base ?: settings.regionUri, path: "/api/v1/getHomeDetail", headers: ['header_clientid':(md5hex(settings.username).bytes.encodeBase64().toString()), 'Authorization': (getLoginData()?.token) ] ] try { asynchttpGet("asyncHttpCallback", params, [method: "getHomeDetail", store: "homeDetail", params:params]) } catch (e) { logWarn "'getHomeDetail()' asynchttpGet() error: $e" } } void getHomeData() { Map rriot = getLoginData()?.rriot String rrHomeId = getHomeDetailData()?.rrHomeId String path = "/v2/user/homes/$rrHomeId" // or "/user/homes/$rrHomeId", Map params = [ uri: rriot?.r?.a, path: path, headers: [ 'Authorization': getHawkAuthentication(rriot?.u, rriot?.s, rriot?.h, path) ] ] try { asynchttpGet("asyncHttpCallback", params, [method: "getHomeData", store: "homeData", params:params]) } catch (e) { logWarn "'getHomeData()' asynchttpGet() error: $e" } } void getHomeRooms() { Map rriot = getLoginData()?.rriot String rrHomeId = getHomeDetailData()?.rrHomeId String path = "/user/homes/$rrHomeId/rooms" Map params = [ uri: rriot?.r?.a, path: path, headers: [ 'Authorization': getHawkAuthentication(rriot?.u, rriot?.s, rriot?.h, path) ] ] try { asynchttpGet("asyncHttpCallback", params, [method: "getHomeRooms", store: "homeRooms", params:params]) } catch (e) { logWarn "'getHomeRooms()' asynchttpGet() error: $e" } } void getDeviceScenes() { Map rriot = getLoginData()?.rriot String path = "/user/scene/device/${getDeviceId()}" Map params = [ uri: rriot?.r?.a, path: path, headers: [ 'Authorization': getHawkAuthentication(rriot?.u, rriot?.s, rriot?.h, path) ] ] try { asynchttpGet("asyncHttpCallback", params, [method: "getDeviceScenes", params:params]) } catch (e) { logWarn "'getDeviceScenes()' asynchttpGet() error: $e" } } void setDeviceScene(String sceneId) { Map rriot = getLoginData()?.rriot String path = "/user/scene/$sceneId/execute" Map params = [ uri: rriot?.r?.a, path: path, headers: [ 'Authorization': getHawkAuthentication(rriot?.u, rriot?.s, rriot?.h, path) ], contentType: "application/json", body: [ sceneId: sceneId ] ] try { asynchttpPost("asyncHttpCallback", params, [method: "setDeviceScene", sceneId: sceneId, params:params]) } catch (e) { logWarn "'setDeviceScene()' asynchttpPost() error: $e" } } void asyncHttpCallback(resp, data) { logDebug "executing 'asyncHttpCallback()' status: ${resp.status} method: ${data?.method}" if (resp.status == 200) { resp.headers.each { logTrace "${it.key} : ${it.value}" } logTrace "response data: ${resp.data}" Map respJson = new JsonSlurper().parseText(resp.data) respJson.timestamp = now() // not used for anything yet. switch(data?.method) { case "getHomeDetail": storeJsonState( data?.store, datetimestring(), respJson ) g_mGetHomeDetail[device.getIdAsLong()]?.clear() g_mGetHomeDetail[device.getIdAsLong()] = respJson getHomeData() break case "getHomeData": synchronized (this) { storeJsonState( data?.store, datetimestring(), respJson ) g_mGetHomeData[device.getIdAsLong()]?.clear() g_mGetHomeData[device.getIdAsLong()] = respJson } getHomeDataCallback() getDeviceScenes() break case "getHomeRooms": // not used //storeJsonState( data?.store, datetimestring(), respJson ) break case "getDeviceScenes": respJson?.result?.each { logTrace it } Map scenes = respJson?.result?.collectEntries{ [(it.id.toString()): it.name] } processEvent("scenes", scenes?.sort()) break case "setDeviceScene": logInfo "${respJson?.status=="ok"?"accepted":"rejected"} sceneId:$data.sceneId" break default: logWarn "asyncHttpGetCallback() ${data?.method} not supported" if (resp?.data) { logInfo resp.data } } } else { logWarn("asyncHttpGetCallback() ${data?.method} status:${resp.status} errorMessage:${resp?.errorMessage?:"none"} params:${data?.params}") logTrace("Available Properties: ${resp.properties}") } } void storeJsonState(String name, String visible, Map hidden) { String encoded64 = JsonOutput.toJson(hidden).getBytes("UTF-8").encodeBase64().toString() state[name] = """[ date: ${visible}, size: ${encoded64.size()} ]""" } Map fetchJsonState(String name) { if(state?."$name"==null) return [:] def slurper = new XmlSlurper().parseText((state[name])) //String visibleText = slurper.span.find { it.@class == 'visible-data' }?.text() String encodedData = slurper?.span?.find { it.@class == 'hidden-data' }?.@'data-hidden' return parseJsonFromBase64( encodedData ) } // Function to find the 'next' device given a 'duid' String findNextDevice(String duid=null) { List sortedDevices = getHomeDataResult()?.devices?.sort{ a, b -> a.duid <=> b.duid } sortedDevices?.result?.products.sort { it.id }?.result?.devices.sort { it.duid } Integer currentIndex = -1 // Check if duid is not null if(duid != null) { // Attempt to find the index of the device with the given duid currentIndex = sortedDevices.findIndexOf { it.duid == duid } } // If duid is null or the device is not found, return the first device if(duid == null || currentIndex == -1) { return sortedDevices[0]?.duid } // Calculate the index of the next device, wrapping around if necessary Integer nextIndex = (currentIndex + 1) % sortedDevices.size() // Retrieve and return the next device return sortedDevices[nextIndex]?.duid } String getDeviceId() { state.duid = state?.duid ?: findNextDevice() return state.duid } String getLocalKey(String deviceId) { return getHomeDataResult()?.devices?.find { it.duid == deviceId }?.localKey } @Field volatile static Map g_mGetLoginData = [:] Map getLoginData() { if(g_mGetLoginData[device.getIdAsLong()] == null) { logDebug "executing 'getLoginData()' cache" g_mGetLoginData[device.getIdAsLong()] = fetchJsonState("login") } return g_mGetLoginData[device.getIdAsLong()]?.data ?: [:] } @Field volatile static Map g_mGetHomeDetail = [:] Map getHomeDetailData() { if(g_mGetHomeDetail[device.getIdAsLong()] == null) { logDebug "executing 'getHomeDetailData()' cache" g_mGetHomeDetail[device.getIdAsLong()] = fetchJsonState("homeDetail") } return g_mGetHomeDetail[device.getIdAsLong()]?.data ?: [:] } @Field volatile static Map g_mGetHomeData = [:] Map getHomeDataResult() { if(g_mGetHomeData[device.getIdAsLong()] == null) { synchronized (this) { logDebug "executing 'getHomeDataResult()' cache" g_mGetHomeData[device.getIdAsLong()] = fetchJsonState("homeData") } } return g_mGetHomeData[device.getIdAsLong()]?.result ?: [:] } // needed a queue to manage publish messages, otherwise the broker will toss them. @Field volatile static Map qQueue = [:] private List qGet() { if(!qQueue[device.getIdAsLong()]) qQueue[device.getIdAsLong()] = [] return qQueue[device.getIdAsLong()] } void qPush(Map map) { qGet().removeIf { now() > it?.ts + 30000 } //remove anything older than 30 seconds map.ts = now() // Add timestamp qGet() << map // Append map to the end of the list } void qClear() { qGet().clear() } Map qPop() { if(qGet().size() > 0) { return qGet().remove(0) // Remove and return the first element } return null } Map qPeek() { return qGet().isEmpty() ? null : qGet()[0] } Boolean qIsEmpty() { return qGet().isEmpty() } Integer qSize() { return qGet().size() } //https://github.com/copystring/ioBroker.roborock/blob/621351f58c6ef6c2d6cd2b9d7525cb8ca763ede8/lib/deviceFeatures.js @Field static final Map errorCodes = [ 0: "No error", 1: "Laser sensor fault", 2: "Collision sensor fault", 3: "Wheel floating", 4: "Cliff sensor fault", 5: "Main brush blocked", 6: "Side brush blocked", 7: "Wheel blocked", 8: "Device stuck", 9: "Dust bin missing", 10: "Filter blocked", 11: "Magnetic field detected", 12: "Low battery", 13: "Charging problem", 14: "Battery failure", 15: "Wall sensor fault", 16: "Uneven surface", 17: "Side brush failure", 18: "Suction fan failure", 19: "Unpowered charging station", 20: "Unknown Error", 21: "Laser pressure sensor problem", 22: "Charge sensor problem", 23: "Dock problem", 24: "No-go zone or invisible wall detected", 254: "Bin full", 255: "Internal error", 256: "Wifi Offline", // added 1.1.2 and deprecated wifi attribute 257: "Authorization error", // added 1.1.5 ] @Field static final List stateDoNotRefreshCodes = [ 0,1,2,3,9,10,12,14,100 ] @Field static final Map stateCodes = [ 0: "Unknown", 1: "Initiating", 2: "Sleeping", 3: "Idle", 4: "Remote Control", 5: "Cleaning", 6: "Returning Dock", 7: "Manual Mode", 8: "Charging", 9: "Charging Error", 10: "Paused", 11: "Spot Cleaning", 12: "In Error", 13: "Shutting Down", 14: "Updating", 15: "Docking", 16: "Go To", 17: "Zone Clean", 18: "Room Clean", 22: "Emptying Dust Bin", 23: "Washing the mop", 26: "Going to wash the mop", 28: "In call", 29: "Mapping", 100: "Charged", 500: "Authorization error", 501: "Authorization Requires PIN", 502: "Waiting for Authorization PIN", 503: "Authorized", 504: "Error requesting PIN", ] @Field static final Map fanPowerCodes = [ 101: "Quiet", 102: "Balanced", 103: "Turbo", 104: "Max", 105: "Off", 106: "Auto", 108: "Max+", ] @Field static final Map mopModeCodes = [ 300: "Standard", 301: "Deep", 302: "Custom", 303: "Deep+", 304: "Fast", ] // https://github.com/marcelrv/XiaomiRobotVacuumProtocol/blob/master/water_box_custom_mode.md @Field static final Map mopWaterModeCodes = [ 0: "Default", 200: "Off", 201: "Low", 202: "Medium", 203: "High", 204: "Auto", 207: "Custom", ] //https://github.com/humbertogontijo/python-roborock/blob/main/roborock/code_mappings.py @Field static final Map dockErrorCodes = [ 0: "No error", 34: "Duct Blockage", 38: "Water Empty", 39: "Waste Water Tank Full", 40: "Water Filter Not Installed", 42: "Check the Water Filter Has Been Correctly Installed", 44: "Dirty Tank Latch Open", 46: "No Dust Bin", 53: "Cleaning Tank Full Blocked", ] /* ============================================================ * LOG HELPERS * ============================================================ */ def logInfo(msg) { if(!deviceInfoDisable) log.info "${device.displayName} ${msg}" } def logDebug(msg) { if(deviceDebugEnable) log.debug "${device.displayName} ${msg}" } def logTrace(msg) { if(deviceTraceEnable) log.trace "${device.displayName} ${msg}" } def logWarn(msg) { log.warn "${device.displayName} ${msg}" } def logError(msg) { log.error "${device.displayName} ${msg}" }