/**
* Copyright 2024 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.0"}
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]
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 "appClean"
command "appDock"
command "appPause"
command "appRoomClean", [[name: "Room IDs*", type: "STRING", description: "Accepts comma 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()
attribute "name", "string"
attribute "rooms", "JSON_OBJECT"
attribute "scenes", "JSON_OBJECT"
attribute "state", "string" // , stateCodes.values().collect() -- too long
attribute "error", "string" // , errorCodes.values().collect() -- too long
attribute "fanPower", "enum", fanPowerCodes.values().collect()
attribute "cleanTime", "number"
attribute "cleanArea", "number"
attribute "cleanPercent", "number"
attribute "remainingFilter", "number"
attribute "remainingMainBrush", "number"
attribute "remainingSensors", "number"
attribute "remainingSideBrush", "number"
attribute "locating", "enum", ["true","false"]
attribute "mopMode", "enum", mopModeCodes.values().collect()
attribute "mopWaterMode", "enum", mopWaterModeCodes.values().collect()
attribute "wifi", "enum", ["offline", "online"]
attribute "healthStatus", "enum", ["offline", "online"]
}
}
preferences {
input(name:"username", type:"string", title: "Account Username:", required: true, width:4)
input(name:"password", type:"password", title: "Account 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:"areaUnit", type:"enum", title: "Device Area Unit:", options:["0":"Square Foot (ft²)", "1":"Square Meter (m²)"], defaultValue: "0", required: true, 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 "${device.displayName} 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()
if(settings?.allowLogin && settings?.username && settings?.password) {
logInfo "${device.displayName} executing 'initialize()' allowLogin"
// blow away all state information
state?.keySet()?.collect()?.each{ state.remove(it) }
state.sequence = (new Random().nextInt(2000) + 1)
clearAttributes()
if(login()?.msg=="success") {
device.updateSetting("allowLogin",[value:'false',type:"bool"])
disconnect()
runIn(1, "getHomeDetail") //runs getHomeData()->getHomeDataCallback() async serial
} else {
logWarn "${device.displayName} login with username:'$username' password:'$password' failed"
}
}
else if(state?.login) {
state.remove("autoRefresh") // removed in 1.0.4
disconnect()
runIn(1, "getHomeData") //runs getHomeDataCallback() async serial
}
}
def push(buttonNumber) {
if( (device.currentValue("numberOfButtons")?.toInteger()?:0) g_mLastRefreshTime = [:]
def refresh(Map data=[type:1]) {
logDebug "${device.displayName} 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))) : []
logInfo "${device.displayName} 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()) {
Map cmd = qPeek()
runIn(15, "watchdog") // unscheduled in processMsg()
publish(cmd.duid, cmd.command, cmd.param, cmd.id)
} else {
unschedule('watchdog')
}
}
void watchdog() {
logWarn "${device.displayName} executing 'watchdog()'"
disconnect()
runIn(1, "getHomeData")
}
void scheduleRefresh(Integer delay=5) {
runIn(delay, "refresh", [data: [type:2]])
}
void disconnect() {
logInfo "${device.displayName} executing 'disconnect()'"
unsubscribe()
interfaces.mqtt.disconnect()
setHealthStatusEvent(false)
}
void connect() {
logDebug "${device.displayName} 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 "${device.displayName} connecting mqttUser:$mqttUser to $rriot.r.m"
interfaces.mqtt.connect(rriot.r.m, "${device.deviceNetworkId}", mqttUser, mqttPassword, byteInterface:true)
}
def mqttClientStatus(String message) {
logInfo "${device.displayName} executing 'mqttClientStatus($message)'"
if(message.toLowerCase().contains("connection succeeded")) {
runIn(1, "subscribe")
}
else {
disconnect()
runIn(60*10, "connect")
}
}
void subscribe() {
logDebug "${device.displayName} 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 "${device.displayName} subscribe topic:$topic"
interfaces.mqtt.subscribe(topic)
runEvery30Minutes(refresh)
scheduleRefresh()
updateHomeData()
executeQueue()
}
void unsubscribe() {
logDebug "${device.displayName} executing 'unsubscribe()'"
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 "${device.displayName} unsubscribe topic:$topic"
interfaces.mqtt.unsubscribe(topic)
}
void sendEventX(Map x) {
if(device.currentValue(x?.name).toString() != x?.value.toString()) {
if(x?.descriptionText) 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 "${device.displayName} 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: "${device.displayName} mop water mode is $valueEnum ($value)")
break
case "switch":
sendEventX(name: "switch", value: value, descriptionText: "${device.displayName} switch is $value")
break
case "name":
sendEventX(name: "name", value: value, descriptionText: "${device.displayName} name set to $value")
break
case "healthStatus":
sendEventX(name: "healthStatus", value: value, descriptionText: "${device.displayName} healthStatus set to $value")
break
case "wifi":
sendEventX(name: "wifi", value: value, descriptionText: "${device.displayName} wifi set to $value")
break
case "rooms":
sendEventX(name: "rooms", value: JsonOutput.toJson(value), descriptionText: "${device.displayName} rooms set to $value")
break
case "scenes":
sendEventX(name: "scenes", value: JsonOutput.toJson(value), descriptionText: "${device.displayName} 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: "${device.displayName} error is $valueEnum ($value)")
break
case "state":
String valueEnum = stateCodes[value?.toInteger()]?.toLowerCase() ?: value
sendEventX(name: "state", value: valueEnum, descriptionText: "${device.displayName} state is $valueEnum ($value)")
break
case "battery":
sendEventX(name: "battery", value: value.toInteger(), unit: "%", descriptionText: "${device.displayName} battery level is $value%")
break
case "fan_power":
String valueEnum = fanPowerCodes[value?.toInteger()]?.toLowerCase() ?: value
sendEventX(name: "fanPower", value: valueEnum, descriptionText: "${device.displayName} 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: "${device.displayName} 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: "${device.displayName} side brush time remaining is $percentAvail%")
break
case "filter_life":
break
case "filter_work_time":
Integer percentAvail = Math.max(0, (100 - Math.floor((value.toInteger() / (life.filter * 60 * 60)) * 100).toInteger()))
sendEventX(name: "remainingFilter", value: percentAvail, unit: "%", descriptionText: "${device.displayName} filter time remaining is $percentAvail%")
break
case "additional_props":
//descriptionText = "${device.displayName} additional props is $value"
//sendEvent(name: "additional_props", value: value.toInteger(), descriptionText: descriptionText)
break
case "task_complete":
break
case "task_cancel_low_power":
break
case "task_cancel_in_motion":
break
case "charge_status":
break
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: "${device.displayName} sensor time remaining is $percentAvail%")
break
case "filter_element_work_time":
break
case "dust_collection_work_times":
break
case "msg_ver":
break
case "msg_seq":
break
case "clean_time":
//Integer totalSeconds = value.toInteger()
//String timeString = String.format("%02d:%02d", (totalSeconds / 3600).intValue(), ((totalSeconds % 3600) / 60).intValue())
//descriptionText = "${device.displayName} clean time is $timeString (hh:mm)"
Integer totalMinutes = Math.ceil(value.toInteger()/60).toInteger()
sendEventX(name: "cleanTime", value: totalMinutes, unit: "min", descriptionText: "${device.displayName} clean time is $totalMinutes ${totalMinutes==1?"minute":"minutes"}")
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: "${device.displayName} clean area is $area $unit")
break
case "map_present":
break
case "in_cleaning":
break
case "in_returning":
break
case "in_fresh_state":
break
case "lab_status":
break
case "water_box_status":
break
case "dnd_enabled":
break
case "map_status":
break
case "is_locating":
String locatingString = (value==0 ? "false" : "true")
sendEventX(name: "locating", value: locatingString, descriptionText: "${device.displayName} locating value is $locatingString ($value)")
break
case "lock_status":
break
case "water_box_carriage_status":
break
case "mop_forbidden_enable":
break
case "camera_status":
break
case "is_exploring":
break
case "adbumper_status":
break
case "water_shortage_status":
break
case "dock_type":
break
case "dust_collection_status":
String dustCollectionString = (value==0 ? "off" : "on")
sendEventX(name: "dustCollection", value: dustCollectionString, descriptionText: "${device.displayName} dust collection is $dustCollectionString ($value)")
break
case "auto_dust_collection":
break
case "avoid_count":
break
case "mop_mode":
String valueEnum = mopModeCodes[value?.toInteger()]?.toLowerCase() ?: value
sendEventX(name: "mopMode", value: valueEnum, descriptionText: "${device.displayName} mop mode is $valueEnum ($value)")
break
case "debug_mode":
break
case "collision_avoid_status":
break
case "switch_map_mode":
break
case "dock_error_status":
String valueEnum = dockErrorCodes[value?.toInteger()]?.toLowerCase() ?: value
sendEventX(name: "dockError", value: valueEnum, descriptionText: "${device.displayName} dock error is $valueEnum ($value)")
break
case "unsave_map_reason":
break
case "unsave_map_flag":
break
case "clean_percent":
sendEventX(name: "cleanPercent", value: value.toInteger(), unit: "%", descriptionText: "${device.displayName} percent completed is $value%")
break
case "rss":
break
case "dss":
break
case "events":
break
case "switch_status":
break
case "distance_off":
case "home_sec_status":
case "home_sec_enable_password":
case "strainer_work_times": // start reported by Q Revo
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": // end reported by Q Revo
break
default:
logDebug "${device.displayName} did not process name:$name with value:$value"
}
if(descriptionText) logInfo descriptionText
}
void processMsg(Map message) {
logDebug "${device.displayName} executing 'processMsg($message)'"
// we have good connection to device since we got a message back from it.
setHealthStatusEvent(true)
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 == duid }?.productId
String code = home?.products?.find{ it.id == productId }?.schema?.find { it.id == key }?.code
if(code=="rpc_response") {
def jsonValue = null
try {
jsonValue = (new JsonSlurper()).parseText( value )
} catch(e) {
logWarn "${device.displayName} message not json: message:$message value:$value"
}
if(qPeek()?.id?.toInteger() != jsonValue?.id?.toInteger()) {
logDebug "${device.displayName} message unknown: command:$cmd result:$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 "${device.displayName} command '$cmd.command' was accepted"
jsonValue?.result?.each{ result ->
if(cmd?.param==["get_status"]) {
result.switch=(result?.in_cleaning?.toInteger()!=0 || result?.is_locating?.toInteger()!=0 || result?.is_exploring?.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 "${device.displayName} processing $result"
result?.each{ c,v -> processEvent(c,v) }
}
}
else if(cmd?.command=="get_room_mapping") {
logDebug "${device.displayName} command '$cmd.command' was accepted"
setRoomsValue(jsonValue)
}
else if(cmd?.command=="get_water_box_custom_mode" && (jsonValue?.result?.water_box_mode)) {
logDebug "${device.displayName} command '$cmd.command' was accepted"
processEvent(cmd?.command, jsonValue.result.water_box_mode)
}
else if(jsonValue?.result==["ok"] || jsonValue?.result==["OK"]) {
logInfo "${device.displayName} command '$cmd.command' was accepted"
scheduleRefresh()
}
else {
logWarn "${device.displayName} message not handled: command:$cmd result:$jsonValue"
}
}
else {
processEvent(code,value)
scheduleRefresh()
}
}
}
void setRoomsValue(Map get_room_mapping) {
logDebug "${device.displayName} 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) {
Boolean deviceOnline = getHomeDataResult()?.devices?.find{ it.duid == getDeviceId() }?.online
String healthStatus = mqttClientStatus && deviceOnline ? "online" : "offline"
processEvent("healthStatus", healthStatus)
}
def parse(String message) {
logDebug "${device.displayName} 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 "${device.displayName} parse message rejected: I am ${state.duid} and this was for $deviceId"
return
}
String localKey = getLocalKey(deviceId)
logDebug "${device.displayName} 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 "${device.displayName} 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 "${device.displayName} 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 "${device.displayName} 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 "${device.displayName} payload was not json. protocol:$protocol, length:${result.length}"
}
} else {
logDebug "${device.displayName} payload protocol:$protocol, length:${result.length}"
}
if(!jsonObject.isEmpty()) {
processMsg( jsonObject )
}
}
Integer publish(String deviceId, method, params, Integer id) {
logDebug "${device.displayName} 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 "${device.displayName} 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")
byte[] initialHash = md.digest(username.bytes)
byte[] saltedHash = md.digest((new String(initialHash) + "${device.deviceNetworkId}").bytes) //adding device.deviceNetworkId to ensure we are unique
return saltedHash.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 "${device.displayName} found username:'${settings.username}' base url:'$response'"
state.base = response
}
} else {
logWarn "${device.displayName} '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) {
storeJsonState( "login", datetimestring(), resp.data )
response = resp.data
} else {
logWarn "${device.displayName} 'getToken()' failure. Status code:${response.getStatus()}"
}
}
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"])
} catch (e) {
logWarn "${device.displayName} '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"])
} catch (e) {
logWarn "${device.displayName} '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"])
} catch (e) {
logWarn "${device.displayName} '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"])
} catch (e) {
logWarn "${device.displayName} '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])
} catch (e) {
logWarn "${device.displayName} 'setDeviceScene()' asynchttpPost() error: $e"
}
}
void asyncHttpCallback(resp, data) {
logDebug "${device.displayName} 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 "${device.displayName} ${respJson?.status=="ok"?"accepted":"rejected"} sceneId:$data.sceneId"
break
default:
logWarn "${device.displayName} asyncHttpGetCallback() ${data?.method} not supported"
if (resp?.data) { logInfo resp.data }
}
}
else {
logWarn("${device.displayName} asyncHttpGetCallback() ${data?.method} status:${resp.status}")
}
}
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()} ]${name} placeholder"""
}
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 "${device.displayName} 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 "${device.displayName} 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 "${device.displayName} 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",
]
@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",
]
@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",
44: "Dirty Tank Latch Open",
46: "No Dust Bin",
53: "Cleaning Tank Full Blocked",
]
private logInfo(msg) { if(settings?.deviceInfoDisable != true) { log.info "${msg}" } }
private logDebug(msg) { if(settings?.deviceDebugEnable == true) { log.debug "${msg}" } }
private logTrace(msg) { if(settings?.deviceTraceEnable == true) { log.trace "${msg}" } }
private logWarn(msg) { log.warn "${msg}" }
private logError(msg) { log.error "${msg}" }