/**
* 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/
* 'functor' https://gitlab.com/functor-solutions/typescript/roborock
*
* Author: bloodtick
* Date: 2024-04-18
*
* Contributors: logname, Anthropic AI
* Date: 2026-03-05
*
* Change history:
*
* 1.1.23 - Q10 S5+ Support: Added explicit fan power, mop mode, auto-empty dock, wash/dry mop commands
* and expanded dock error codes for Roborock Q10 S5+ (2025)
* Direct DPS writes for action commands (DPS 201-206) on B01 protocol devices (2026)
* Correct B01 IV derivation: md5(randomHex8+salt)[9:25] using header random field (2026)
* Correct B01 payload: DPS 10000/10001 with translated method names (2026)
* Skip get_prop RPC on B01 - status from deviceStatus in home data (2026)
* 1.1.24 - Bug Fix: MQTT broker drops connection after ~60 seconds
* 1.1.25 - Bug Fix: appSetWaterVolume — command ignored by robot
* 1.1.26 - Bug Fix: mopWaterMode attribute never updated in Hubitat
* 1.1.27 - UI Cleanup: commands and preferences hidden for Q10 S5+
* 1.1.28 - UI Cleanup: appWashMop hidden; suctionPower attribute declared
* 1.1.29 - Bug Fix: ensureConnected polling loop; appSelectMap pause 1s→2s; suctionPower attribute fix
* 1.1.30 - Bug Fix: suctionPower reverts to number after cloud poll (fan_power B01 raw value not normalized)
* 1.1.31 - Feature: currentMap attribute shows selected map name; updated by appSelectMap and appGetMaps
*/
public static String version() {return "1.1.31-q10s5plus"}
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"
@Field static final String B01_SALT = "5wwh9ikChRjASpMU8cxg7o1d2E" // B01 IV derivation salt from librrcodec.so
// 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 Q10 S5+ 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 "appResume"
command "appStop"
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"]] // Hidden: not supported on Q10 S5+
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"
// Q10 S5+ explicit control commands
// command "appSetFanPower", [[name: "Fan Power*", type: "ENUM", description: "Set suction level", constraints: fanPowerCodes.values().collect{ it.toUpperCase() }]] // Hidden: use appSetSuctionPower instead (Q10 S5+ specific)
// Q10 S5+ fan power is a subset: only Quiet/Balanced/Turbo/Max/Max+ are valid
command "appSetSuctionPower",[[name: "Level*", type: "ENUM", description: "Q10 S5+ suction power", constraints: ["Quiet","Balanced","Turbo","Max","Max+"]]]
// command "appSetMopMode", [[name: "Mop Mode*", type: "ENUM", description: "Set mop mode", constraints: mopModeCodes.values().collect{ it.toUpperCase() }]] // Hidden: use appSetCleanMode instead (Q10 S5+ specific)
// Q10 S5+ specific commands
command "appSetWaterVolume", [[name: "Level*", type: "ENUM", description: "Set water volume (Q10 S5+)", constraints: ["Low","Medium","High"]]]
command "appSetCleanMode", [[name: "Mode*", type: "ENUM", description: "Set cleaning mode (Q10 S5+)", constraints: ["Vac & Mop","Vacuum","Mop","Vac then Mop"]]]
command "appGetMaps", []
command "appSelectMap", [[name: "Map ID or Name*", type: "STRING", description: "Select map by numeric ID or name (run appGetMaps first)"]]
command "appEmptyDust"
// command "appWashMop" // Hidden: not supported on Q10 S5+
// command "appDryMop" // Hidden: not supported on Q10 S5+
command "forceReconnect"
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 "suctionPower", "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 "maps", "string" // JSON list of available maps [{id,name}] (Q10 S5+)
attribute "currentMap", "string" // Name of currently selected map (Q10 S5+)
attribute "mopWaterMode", "enum", mopWaterModeCodes.values().collect{ it.toLowerCase() }
attribute "healthStatus", "enum", ["offline", "online"]
attribute "washStatus", "enum", ["idle", "washing", "drying"] // Q10 S5+
}
}
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:"manualDuid", ...) // Hidden: manual device ID override - uncomment if auto-discovery fails
// input(name:"manualLocalKey", ...) // Hidden: manual local key override - uncomment if auto-discovery fails
// input(name:"cloudOnlyMode", ...) // Hidden: always true for Q10 S5+ - do not disable
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(43200, "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() {
String pvC = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvC == "B01") {
// Q10 S5+: DPS 201 = {"cmd":1} (startGlobalClean)
// Source: @functor/roborock MessageSenderB01_Q10.startGlobalClean -> DPS start_clean=201, value={"cmd":1}
publishDps(getDeviceId(), ["201": [cmd:1]])
return
}
execute("app_start")
}
def appDock() {
String pvD = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvD == "B01") {
// Q10 S5+: DPS 203 = 0 (dock/start_recharge)
// Source: @functor/roborock MessageSenderB01_Q10.dock -> DPS start_dock=203, value=0
publishDps(getDeviceId(), ["203": 0])
return
}
execute("app_charge")
}
def appPause() {
String pvP = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvP == "B01") {
// Q10 S5+: DPS 204 = 0 (pause_clean)
// Source: @functor/roborock MessageSenderB01_Q10.pauseClean -> DPS pause_clean=204, value=0
publishDps(getDeviceId(), ["204": 0])
return
}
execute("app_pause")
}
def appStop() {
String pvSt = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvSt == "B01") {
// Q10 S5+: DPS 206 = 0 (stop_clean)
// Source: @functor/roborock MessageSenderB01_Q10.stopClean -> DPS stop_clean=206, value=0
publishDps(getDeviceId(), ["206": 0])
return
}
execute("app_stop")
}
def appResume() {
String pvR2 = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvR2 == "B01") {
// Q10 S5+: DPS 205 = 0 (resume_clean)
// Source: @functor/roborock MessageSenderB01_Q10.resumeClean -> DPS resume_clean=205, value=0
publishDps(getDeviceId(), ["205": 0])
return
}
execute("app_start")
}
def appRoomResume() { execute("resume_segment_clean") }
def appRoomClean(String rooms, String mopWater=mopWaterModeCodes[0]) {
// Accepts comma/space-delimited room IDs (numeric) or room names
// Looks up names in the rooms state to convert to numeric segment IDs
List roomTokens = rooms.split(/[ ,]+/).collect{ it.trim() }.findAll{ it }
// Get current rooms map: {segmentId: roomName}
Map roomsState = [:]
try { roomsState = (new JsonSlurper()).parseText(device.currentValue("rooms") ?: "{}") } catch(e) {}
List segmentIds = roomTokens.collect { token ->
if (token.isInteger()) {
return token.toInteger()
} else {
// Try to find segment ID by room name (case-insensitive)
def entry = roomsState.find { k, v -> v?.toString()?.equalsIgnoreCase(token) }
if (entry) {
logInfo "appRoomClean: resolved room name '${token}' -> segment ${entry.key}"
return entry.key.toInteger()
} else {
logWarn "appRoomClean: unknown room name '${token}' - available rooms: ${roomsState}"
return null
}
}
}.findAll { it != null }
if (segmentIds.isEmpty()) {
logWarn "appRoomClean: no valid room IDs found from input: '${rooms}'"
return
}
logInfo "appRoomClean: cleaning segments ${segmentIds}"
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")
}
// B01 devices use DPS 201 = {"cmd":2, "clean_paramters": [roomIds]}
// Source: @functor/roborock MessageSenderB01_Q10.startRoomClean -> DPS start_clean=201, value={"cmd":2,"clean_paramters":roomIds}
String pvRoom = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvRoom == "B01") {
ensureConnected()
publishDps(getDeviceId(), ["201": [cmd:2, clean_paramters:segmentIds]])
return
}
execute("app_segment_clean", new groovy.json.JsonBuilder(segmentIds).toString())
}
def appScene(String sceneId) { setDeviceScene(sceneId) }
// Q10 S5+ explicit fan power control: looks up the numeric code by name and sends set_fan_speed
def appSetFanPower(String fanPower) {
Integer code = fanPowerCodes.find { it.value.toUpperCase() == fanPower?.toUpperCase() }?.key
if(code == null) { logWarn "appSetFanPower: unknown fan power '$fanPower'"; return }
String pvFan = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvFan == "B01") {
// Q10 S5+: DPS 123 (vacuum_mode), value = fanCode - 100
// Valid Q10 values: 1(Quiet) 2(Balanced) 3(Turbo) 4(Max) 8(Max+)
// NOTE: fanPowerCodes includes Off(105→5) and Auto(106→6) which are INVALID on Q10
// Use appSetSuctionPower for Q10 devices to avoid sending invalid values
Integer b01Val = code - 100
if(b01Val == 5) { logWarn "appSetFanPower: 'Off' (value 5) is not valid on Q10 S5+ - use appSetSuctionPower"; return }
publishDps(getDeviceId(), ["123": b01Val])
return
}
execute("set_fan_speed", "[$code]")
}
// Q10 S5+ dedicated suction power control (confirmed working values only)
// Source: log testing - valid DPS 123 values: 1,2,3,4,8
// Values 5 and 6 are invalid on Q10 (5 gets remapped to 8 by the robot)
def appSetSuctionPower(String level) {
Map q10FanMap = [Quiet:1, Balanced:2, Turbo:3, Max:4, "Max+":8]
Integer val = q10FanMap.find { it.key.equalsIgnoreCase(level) }?.value
if(val == null) { logWarn "appSetSuctionPower: unknown level '$level' (use: ${q10FanMap.keySet().join(', ')})"; return }
ensureConnected()
// Source: confirmed working via log 4 testing - DPS 123 direct write
publishDps(getDeviceId(), ["123": val])
logInfo "appSetSuctionPower: set to $level (DPS 123=$val)"
}
// Q10 S5+ explicit mop mode control: looks up numeric code and sends set_mop_mode
def appSetMopMode(String mopMode) {
Integer code = mopModeCodes.find { it.value.toUpperCase() == mopMode?.toUpperCase() }?.key
if(code == null) { logWarn "appSetMopMode: unknown mop mode '$mopMode'"; return }
execute("set_mop_mode", "[$code]")
}
// Q10 S5+: Water volume control (Low/Medium/High)
// DPS 124 direct write is silently ignored by Q10. Use prop.set RPC via DPS 10000 instead.
// Source: @functor/roborock Q7 pattern: DPS 10000 prop.set{water:1/2/3}
// Values confirmed from robot push: water:3=High (initial state in log 4)
def appSetWaterVolume(String level) {
Map q10WaterCodes = ["Low":1, "Medium":2, "High":3]
Integer val = q10WaterCodes.find { it.key.equalsIgnoreCase(level) }?.value
if(val == null) { logWarn "appSetWaterVolume: unknown level '$level' (use Low/Medium/High)"; return }
String pvW = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvW == "B01") {
// DPS 124 direct write - same mechanism as fan power (DPS 123), confirmed working
publishDps(getDeviceId(), ["124": val])
logInfo "appSetWaterVolume: set to $level (DPS 124=$val)"
return
}
Map v1WaterCodes = [Low:201, Medium:202, High:203]
Integer v1Code = v1WaterCodes[level] ?: 201
execute("set_water_box_custom_mode", "[$v1Code]")
}
// Q10 S5+: Cleaning mode (Vac & Mop / Vacuum / Mop / Vac then Mop)
// Direct DPS 137 write is ignored/reverted by Q10. Try prop.set RPC via DPS 10000.
// Source: @functor/roborock Q7 pattern uses prop.set{mode:N} where values differ:
// Q7 CleanModeSerializer: VACUUM_ONLY→0, VACUUM_AND_MOP→1, MOP_ONLY→2
// iOS app modes: Vac & Mop, Vacuum, Mop, Vac then Mop, Customize
def appSetCleanMode(String mode) {
// Try both DPS 137 direct AND prop.set RPC - one may stick
// prop.set "mode" values (Q7 CleanMode index): Vacuum=0, Vac&Mop=1, Mop=2, VacThenMop=6
Map propSetModes = ["Vac & Mop":1, "Vacuum":0, "Mop":2, "Vac then Mop":6]
// DPS 137 values (Q10 CleanModeSerializerB01_Q10): Vac&Mop=1, Vacuum=2, Mop=3, VacThenMop=6
Map dps137Modes = ["Vac & Mop":1, "Vacuum":2, "Mop":3, "Vac then Mop":6]
Integer propVal = propSetModes.find { it.key.equalsIgnoreCase(mode) }?.value
Integer dpsVal = dps137Modes.find { it.key.equalsIgnoreCase(mode) }?.value
if(propVal == null) { logWarn "appSetCleanMode: unknown mode '$mode' (use: ${propSetModes.keySet().join(', ')})"; return }
String pvCM = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvCM == "B01") {
ensureConnected()
// Send prop.set RPC first (Q7-style - may work on Q10 too)
Integer msgId = (Integer)(now() & 0x7FFFFFFF)
publish(getDeviceId(), "prop.set", [mode:propVal], msgId)
// Also send direct DPS 137 as fallback
pauseExecution(300)
publishDps(getDeviceId(), ["137": dpsVal])
logInfo "appSetCleanMode: sent prop.set{mode:$propVal} + DPS 137=$dpsVal for '$mode'"
return
}
logWarn "appSetCleanMode: not supported on V1 protocol"
}
// Q10 S5+: Request map list from robot (DPS 101 multimap op:list)
// Robot responds with map list containing real numeric IDs and display names
// Source: @functor/roborock MessageSenderB01_Q10.getMaps
def appGetMaps() {
String pvM = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvM != "B01") { logWarn "appGetMaps: only supported on B01 devices"; return }
logInfo "appGetMaps: requesting map list from robot"
publishDps(getDeviceId(), ["101": ["61": [op:"list"]]])
// The robot's response will come back as DPS 61 inside DPS 101 response
// It should appear in the multimap response handler and populate the 'maps' attribute
}
// Q10 S5+: Map selection by name or numeric ID
// IMPORTANT: The robot uses numeric IDs, not text names like "Map1"
// Call appGetMaps() first to populate the maps attribute, then use the numeric ID shown
// Source: @functor/roborock MessageSenderB01_Q10.loadMap
def appSelectMap(String mapIdOrName) {
String pvM = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvM != "B01") { logWarn "appSelectMap: only supported on B01 devices"; return }
// Try to resolve name to numeric ID from stored maps attribute
String resolvedId = mapIdOrName
try {
def mapsJson = device.currentValue("maps")
if(mapsJson) {
def mapList = new groovy.json.JsonSlurper().parseText(mapsJson)
def match = mapList?.find { it?.name?.equalsIgnoreCase(mapIdOrName) }
if(match?.id) {
resolvedId = match.id.toString()
logInfo "appSelectMap: resolved name '$mapIdOrName' → id '$resolvedId'"
}
}
} catch(e) { logDebug "appSelectMap: could not resolve name from maps attribute: $e" }
logInfo "appSelectMap: selecting map id='$resolvedId'"
ensureConnected()
publishDps(getDeviceId(), ["101": ["61": [op:"select", id:resolvedId]]])
pauseExecution(2000) // cloud round-trip: allow robot to acknowledge select before apply
publishDps(getDeviceId(), ["101": ["61": [op:"apply", id:resolvedId]]])
// Resolve the display name: use original input if it was a name, otherwise look up from maps list
String selectedName = mapIdOrName
try {
def mapsJson = device.currentValue("maps")
if (mapsJson) {
def mapList = new groovy.json.JsonSlurper().parseText(mapsJson)
def match = mapList?.find { it?.id?.toString() == resolvedId }
if (match?.name) selectedName = match.name.toString()
}
} catch(e) { logDebug "appSelectMap: could not resolve name for currentMap: $e" }
sendEvent(name: "currentMap", value: selectedName, descriptionText: "current map is $selectedName")
logInfo "appSelectMap: select+apply sent for '$selectedName'. Wait 3+ seconds before starting clean."
}
// Q10 S5+ auto-empty dock controls
def appEmptyDust() {
String pvE = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if(pvE == "B01") {
// Q10 S5+: DPS 203 = 2 (empty dust bin)
// Source: @functor/roborock MessageSenderB01_Q10.emptyDustBin -> DPS start_dock=203, value=2
publishDps(getDeviceId(), ["203": 2])
return
}
execute("app_start_collect_dust")
}
def appWashMop() { execute("app_start_wash_towel") }
def appDryMop() { execute("app_start_dry_towel") }
def forceReconnect() {
logInfo "forceReconnect: clearing login cache and re-initializing"
unschedule()
disconnect()
g_mGetLoginData[device.getIdAsLong()]?.clear()
g_mGetLoginData[device.getIdAsLong()] = null
g_mGetHomeData[device.getIdAsLong()]?.clear()
g_mGetHomeData[device.getIdAsLong()] = null
state.remove('duid')
device.updateSetting("allowLogin",[value:'true',type:"bool"])
runIn(2, "initialize")
}
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() {
// Q10 S5+: if manual duid/localKey set and no device found via API, inject it
if (settings?.manualDuid && settings?.manualLocalKey && !getAllDevices()?.find { it.duid == settings.manualDuid }) {
logDebug "getHomeDataCallback: injecting manual device duid=${settings.manualDuid}"
mergeUserDevicesIntoHomeData([ [duid: settings.manualDuid, localKey: settings.manualLocalKey, name: "Q10 S5+ (manual)", online: true, productId: "1O9BlCxWe4loekRE624qPv"] ])
return
}
logDebug "executing 'getHomeDataCallback()' ${getHomeDataResult()}"
// Explicitly persist duid to state here - getDeviceId() alone is not reliable
// across async execution contexts in Hubitat's sandbox
if (!state.duid) {
String foundDuid = findNextDevice()
if (foundDuid) {
state.duid = foundDuid
logDebug "getHomeDataCallback: persisted duid=${state.duid}"
}
}
logDebug "device id is ${getDeviceId()}"
Boolean deviceOnline = !!(getAllDevices()?.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()'"
// B01 protocol devices (Q10 S5+) do not respond to get_room_mapping and rooms:[].
// Sending it blocks the queue permanently. Skip it for B01 devices.
String pv = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
if (pv != "B01") {
execute("get_room_mapping")
if(device.currentValue("switch")!="on") execute("get_consumable")
} else {
logDebug "updateHomeData: B01 device - applying deviceStatus from home data immediately"
// For B01 devices, immediately surface the deviceStatus from the cloud home data.
// This provides battery, state, brush life etc. without waiting for MQTT responses.
Map deviceStatus = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.deviceStatus
if (deviceStatus) {
Map home = getHomeDataResult()
String duid = getDeviceId()
String productId = getAllDevices()?.find{ it.duid?.toString() == duid?.toString() }?.productId
deviceStatus.each { k, v ->
String code = home?.products?.find{ it.id == productId }?.schema?.find { it.id?.toString() == k?.toString() }?.code
if (code && code != "rpc_request" && code != "rpc_response") {
logDebug "updateHomeData: deviceStatus ${code}(${k})=${v}"
processEvent(code, v)
}
}
}
}
String name = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.name ?: "unknown"
processEvent("name", name)
}
@Field volatile static Map g_mLastRefreshTime = [:]
def refresh() { refresh([type:1]) }
def refresh(Map data) {
logDebug "executing 'refresh($data)'"
String pvR = getAllDevices()?.find{ it.duid?.toString() == getDeviceId() }?.pv ?: "1.0"
// B01 devices get status from deviceStatus in home data - skip the get_prop RPC (robot ignores it)
if(pvR != "B01") {
execute("get_prop", """["get_status"]""")
if(device.currentValue("switch")=="on") execute("get_consumable")
} else {
logDebug "refresh: B01 device - skipping get_prop RPC (status from deviceStatus)"
}
if(g_mLastRefreshTime[device.getIdAsLong()] == null) g_mLastRefreshTime[device.getIdAsLong()] = now()-120000
// B01 devices: call getHomeData on every refresh since state comes from cloud deviceStatus (not MQTT).
// Use a 5-second throttle to avoid hammering the cloud but still get near-realtime updates.
// V1 devices: keep 2-minute throttle (they get state from get_prop MQTT responses).
Long refreshThreshold = (pvR == "B01") ? 5000L : 120000L
if((now() - g_mLastRefreshTime[device.getIdAsLong()]) > refreshThreshold) {
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()
// B01 cloud-only devices need more time; 15s is too tight for cloud MQTT round-trips
String pv2 = getAllDevices()?.find{ it.duid?.toString() == cmd?.duid?.toString() }?.pv ?: "1.0"
Integer watchdogSecs = (pv2 == "B01") ? 30 : 15
runIn(watchdogSecs, "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
// First: purge any items that have no id - these are orphaned B01 prop.set items
// that were incorrectly pushed to the V1 queue and can never be acknowledged
qGet().removeIf { it?.id == null }
if(qIsEmpty()) return
logInfo "executing 'watchdog()' on queue:${qPeek()}"
// Pop the timed-out command rather than looping forever
Map timedOut = qPop()
logWarn "watchdog: dropping timed-out command ${timedOut?.command} id:${timedOut?.id}"
disconnect()
// Reconnect using cached login data - do NOT call initialize() which re-attempts login
runIn(2, "connect")
}
void scheduleRefresh(Integer delay=5) {
logDebug "executing 'scheduleRefresh($delay)'"
// overwrite:true means repeated calls within the window collapse to one event,
// preventing a runIn() storm when many B01 status pushes arrive in a burst
runIn(delay, "refresh", [data: [type:2], overwrite: true])
}
// Ensure MQTT is connected before sending a command.
// Called by appSetSuctionPower, appSetWaterVolume, appSetCleanMode, appSelectMap etc.
// Uses the same reconnect path as watchdog - no re-login, just reconnect.
void ensureConnected() {
if (!interfaces.mqtt.isConnected()) {
logInfo "ensureConnected: MQTT not connected, reconnecting before command..."
connect()
// Poll until the broker confirms connection (subscribe() sets isConnected).
// Typically resolves in 1.5-2.5s. Cap at 5s to avoid blocking the platform too long.
Integer waited = 0
while (!interfaces.mqtt.isConnected() && waited < 5000) {
pauseExecution(250)
waited += 250
}
if (!interfaces.mqtt.isConnected()) {
logWarn "ensureConnected: timed out waiting for MQTT - command may fail"
} else {
logDebug "ensureConnected: connected after ${waited}ms"
}
}
}
void disconnect() {
logInfo "executing 'disconnect()'"
state.mqttConnected = false
try { unsubscribe() } catch (Exception e) { logDebug "disconnect: unsubscribe skipped (${e.message})" }
try { interfaces.mqtt.disconnect() } catch (Exception e) { logDebug "disconnect: already disconnected (${e.message})" }
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"
// Note: do NOT set state.mqttConnected here - connection is async.
// state.mqttConnected is set true in subscribe() once the broker confirms and we've subscribed.
} 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}'"
processEvent("error_code", 257)
// Use the configured autoLogin interval - rapid re-login invalidates tokens and locks accounts
if(settings?.autoLogin && settings.autoLogin!="manual") {
logInfo "auth failed - scheduling re-login in ${settings.autoLogin} seconds"
unschedule()
device.updateSetting("allowLogin",[value:'true',type:"bool"])
state.restore = state.duid
g_mGetLoginData[device.getIdAsLong()]?.clear()
g_mGetLoginData[device.getIdAsLong()] = null
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")) {
unschedule('setHealthStatusEvent') // cancel any pending offline timer from prior disconnect()
runIn(1, "subscribe")
}
else {
disconnect()
runIn(30, "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)
state.mqttConnected = true // connection is live and subscribed - safe to publish now
runEvery30Minutes(refresh)
runIn(55, "mqttKeepAlive") // keep broker connection alive; re-schedules itself every 55s
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)
}
}
// Keeps the Roborock broker from dropping idle connections.
// The broker closes connections with no traffic within ~60 seconds.
// This publishes nothing but re-schedules itself every 55s while connected.
void mqttKeepAlive() {
if (interfaces.mqtt.isConnected()) {
logDebug "mqttKeepAlive: connection alive"
runIn(55, "mqttKeepAlive")
} else {
logDebug "mqttKeepAlive: not connected - skipping reschedule"
}
}
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":
// B01 cloud deviceStatus sends raw values 1-8; MQTT push path already maps to 101-108.
// Normalize here so both paths produce consistent labels.
Integer fanRaw = value?.toInteger()
if (fanRaw != null && fanRaw < 100) fanRaw = 100 + fanRaw
String valueEnum = fanPowerCodes[fanRaw]?.toLowerCase() ?: value
sendEventX(name: "suctionPower", value: valueEnum, descriptionText: "suction power is $valueEnum ($value)")
break
case "water_box_mode":
// B01 sends water 1/2/3; V1 sends 201/202/203. Normalize to label for mopWaterMode attribute.
Integer waterRaw2 = value?.toInteger()
if (waterRaw2 >= 201) waterRaw2 = waterRaw2 - 200 // V1 passthrough
String waterLabel = [1:"low", 2:"medium", 3:"high"][waterRaw2] ?: value?.toString()
sendEventX(name: "mopWaterMode", value: waterLabel, descriptionText: "water volume is $waterLabel ($value)")
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":
// Q10 S5+: 0=idle, 1=washing, 2=drying
String washStr = (value == 0 ? "idle" : (value == 1 ? "washing" : (value == 2 ? "drying" : value.toString())))
sendEventX(name: "washStatus", value: washStr, descriptionText: "wash status is $washStr ($value)")
break
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 = getAllDevices()?.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 = getAllDevices()?.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)
logWarn "DIAG parse incoming topic:${mqttMessage.topic} payload[${mqttMessage.payload.length()/2}B]:${mqttMessage.payload.take(40)}"
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)
// Accept both "1.0" (older models) and "B01" (Q10 S5+ and newer cloud-only models)
if (version != "1.0" && version != "B01") {
logWarn "parse: unexpected version:$version, Message hex: ${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)
// Protocol 102 = command responses (both B01 and V1)
// Protocol 301 = B01 map data (unsupported), 300 = photos (unsupported)
if (protocol != 102) { logDebug "parse: dropping protocol:$protocol (only handle 102)"; return }
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)}"
// B01 debug: log first 48 bytes of payload to verify nonce structure
if (version == "B01" && payloadLen >= 48) {
int previewLen = Math.min(payload.length, 48)
byte[] preview = copyBytes(payload, 0, previewLen)
logDebug "parse: B01 payload preview (first ${previewLen}B): ${preview.encodeHex()}"
}
// Determine decryption strategy based on protocol version
String pv = getAllDevices()?.find{ it.duid?.toString() == deviceId }?.pv ?: "1.0"
byte[] result
if (pv == "B01") {
// B01 protocol: AES-128-CBC
// IV = md5(randomHex8 + B01_SALT)[9:25] using the header random uint32
if (payload.length < 16) {
logDebug "parse: B01 payload too short (${payload.length} bytes), skipping"
return
}
try {
byte[] aesKey = localKey.getBytes("UTF-8")
String ivHex = md5hex(String.format("%08x", random & 0xFFFFFFFFL) + B01_SALT).substring(9, 25)
byte[] iv = ivHex.getBytes("UTF-8")
logDebug "parse: B01 random=${random}, iv=${ivHex}, encryptedLen=${payload.length}"
javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(iv)
Cipher cbcCipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cbcCipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(aesKey, "AES"), ivSpec)
result = cbcCipher.doFinal(payload)
logDebug "parse: B01 decrypted successfully (${result.length} bytes)"
} catch(Exception e) {
logWarn "parse: B01 decrypt error: ${e.message}"
return
}
} else {
// 1.0 protocol: key derived from message timestamp
String key = encodeTimestamp(timestamp) + localKey + salt
try {
result = decrypt(payload, key)
} catch(javax.crypto.BadPaddingException e) {
logDebug "parse: skipping push message (bad padding with localKey)"
return
} catch(Exception e) {
logWarn "parse: decrypt error: ${e.message}"
return
}
}
Map jsonObject = [:]
// Parse decrypted payload
String pvMsg = getAllDevices()?.find{ it.duid?.toString() == deviceId }?.pv ?: "1.0"
if (pvMsg == "B01") {
// B01 decrypted payload: {"dps":{"10001":{...}}, "t":...}
try {
Map outerJson = (new JsonSlurper()).parseText( new String(result, "UTF-8") )
logDebug "parse: B01 decrypted JSON keys: ${outerJson?.keySet()}"
def inner = outerJson?.dps?."10001"
if (inner == null) inner = outerJson // fallback: payload IS the response
if (inner instanceof String) {
try { inner = (new JsonSlurper()).parseText(inner) } catch(ex) { }
}
setHealthStatusEvent(true)
// prop.post = unsolicited real-time status push from robot (named keys)
// Sent after commands and during cleaning - extract state, battery, wind etc immediately
String b01Method = inner?.method?.toString()
if (b01Method == "prop.post") {
def params = inner?.params
if (params instanceof Map && !params.isEmpty()) {
logDebug "B01 prop.post unsolicited status: ${params}"
processB01StatusData(params)
}
// prop.post is a push, not a command response - do NOT pop the queue
scheduleRefresh(5)
executeQueue()
return
}
// Also handle raw DPS numeric key updates pushed by Q10 (no method field)
// e.g. outer DPS: {"dps":{"121":102, "122":95, "123":2}, "t":...}
// These are status broadcasts using DPS numbers (121=state, 122=battery, 123=wind)
// Also handles DPS 101 (common/multimap) responses from the robot
if (b01Method == null && outerJson?.dps instanceof Map) {
Map dpsMap = outerJson.dps
Map namedStatus = [:]
if (dpsMap["121"] != null) namedStatus.state = dpsMap["121"]
if (dpsMap["122"] != null) namedStatus.battery = dpsMap["122"]
if (dpsMap["123"] != null) namedStatus.wind = dpsMap["123"]
if (dpsMap["124"] != null) namedStatus.water = dpsMap["124"]
if (dpsMap["137"] != null) namedStatus.clean_mode = dpsMap["137"]
if (dpsMap["120"] != null) namedStatus.error = dpsMap["120"]
// DPS 101 = common channel (multimap responses, etc.)
if (dpsMap["101"] instanceof Map) {
def common = dpsMap["101"]
if (common["61"] instanceof Map) {
def mapData = common["61"]
logDebug "B01 multimap response: ${mapData}"
processB01MapData(mapData)
}
}
// DPS 61 = multimap (can also arrive unwrapped)
if (dpsMap["61"] instanceof Map) {
logDebug "B01 multimap direct: ${dpsMap['61']}"
processB01MapData(dpsMap["61"])
}
if (!namedStatus.isEmpty()) {
logDebug "B01 DPS numeric status push: ${namedStatus}"
processB01StatusData(namedStatus)
scheduleRefresh(5)
executeQueue()
return
}
}
Map cmd = qPop()
if(cmd) logInfo "B01 response: command '${cmd.command}' acknowledged (id=${inner?.id ?: inner?.msgId})"
// Extract status from response if present (prop.get response has data field)
def statusData = inner?.data ?: inner?.result
if (statusData instanceof Map && (statusData.containsKey("state") || statusData.containsKey("wind") || statusData.containsKey("battery"))) {
logDebug "B01 prop.get response data: ${statusData}"
processB01StatusData(statusData)
}
scheduleRefresh(5)
executeQueue()
} catch(e) {
logDebug "parse: B01 non-JSON payload (${result.length} bytes), acknowledging"
setHealthStatusEvent(true)
Map cmd = qPop()
if(cmd) logInfo "B01 response: command '${cmd.command}' acknowledged (binary)"
scheduleRefresh(5)
executeQueue()
}
return
}
if(protocol==102) {
try {
jsonObject = (new JsonSlurper()).parseText( new String(result, "UTF-8") )
} catch(e) {
logWarn "parse: failed to parse protocol 102 JSON: ${e.message}"
return
}
} else {
logDebug "payload protocol:$protocol, length:${result.length}"
}
if(!jsonObject.isEmpty()) {
processMsg( jsonObject )
}
}
// Process B01 status data from prop.get response
void processB01StatusData(Map data) {
if (data.state != null) processEvent("state", data.state)
if (data.battery != null) processEvent("battery", data.battery)
// B01 sends fan power as "wind" with values 1-5 (not 101-105 used by V1 protocol)
// Map B01 wind 1-5 to standard fan power codes 101-105 for display
if (data.wind != null) {
Integer windRaw = data.wind?.toInteger()
// B01 wind 1=Quiet(101), 2=Balanced(102), 3=Turbo(103), 4=Max(104), 5=Off(105)
Integer mappedFan = (windRaw >= 1 && windRaw <= 5) ? (100 + windRaw) : windRaw
processEvent("fan_power", mappedFan)
}
if (data.error != null) processEvent("error_code", data.error)
// Also handle "status" key which B01 uses as an alias for "state"
if (data.status != null && data.state == null) processEvent("state", data.status)
// Q10 water mode: robot pushes water 1-3; pass raw value - processEvent case handles label mapping
if (data.water != null) processEvent("water_box_mode", data.water)
if (data.clean_mode != null) processEvent("cleaning_preference", data.clean_mode)
logDebug "B01 processB01StatusData: ${data}"
}
// B01 multimap response parser
// Parses the map list response from DPS 61 and stores in 'maps' attribute
// Response format from robot: {op:"list", data:[{id:"123456", name:"Map1"}, ...]}
// or {op:"result", data:[...]} depending on firmware
void processB01MapData(Map mapData) {
try {
def op = mapData?.op?.toString()
def data = mapData?.data
logDebug "processB01MapData: op=$op data=${data}"
if (data instanceof List && !data.isEmpty()) {
// Store map list as JSON: [{id:"123456",name:"Map1"}, ...]
String mapsJson = groovy.json.JsonOutput.toJson(data)
device.updateDataValue("mapList", mapsJson)
sendEvent(name:"maps", value:mapsJson)
// Log all discovered maps for user reference
data.each { m ->
logInfo "B01 map discovered: name='${m?.name}' id='${m?.id}'"
}
logInfo "B01 maps updated: ${data.size()} maps found. Use numeric IDs with appSelectMap."
// Update currentMap: auto-set if only one map; validate existing selection if multiple
if (data.size() == 1) {
String onlyMap = data[0]?.name?.toString() ?: data[0]?.id?.toString()
sendEvent(name: "currentMap", value: onlyMap, descriptionText: "current map is $onlyMap")
} else {
String existing = device.currentValue("currentMap")
boolean stillValid = existing && data.any { it?.name?.toString() == existing }
if (!stillValid) sendEvent(name: "currentMap", value: "unknown", descriptionText: "current map is unknown - use appSelectMap")
}
} else if (op == "select" || op == "apply") {
logDebug "B01 multimap ack: op=$op"
}
} catch(e) {
logWarn "processB01MapData error: $e"
}
}
// B01 protocol method translation table
// Source: ioBroker.roborock B01ControlService + messageParser.buildPayload
// B01 devices do NOT use V1 RPC method names (app_start, get_prop, etc.)
// Instead they use service.* and prop.* methods via DPS "10000"
Map translateB01Method(String method, def params) {
switch(method) {
case "app_start":
return [method:"service.set_room_clean", params:[clean_type:0, ctrl_value:1, room_ids:[]]]
case "app_pause":
return [method:"service.set_room_clean", params:[clean_type:0, ctrl_value:2, room_ids:[]]]
case "app_charge":
return [method:"service.start_recharge", params:[:]]
case "app_stop":
return [method:"service.set_room_clean", params:[clean_type:0, ctrl_value:2, room_ids:[]]]
case "find_me":
return [method:"service.find_device", params:[:]]
case "get_prop":
// get_prop ["get_status"] -> prop.get {property: ["get_status"]}
def propParams = params instanceof List ? params : [params]
return [method:"prop.get", params:[property:propParams]]
case "app_start_collect_dust":
return [method:"service.start_dock_task", params:[dock_task_type:1]]
case "app_start_wash_towel":
return [method:"service.start_dock_task", params:[dock_task_type:2]]
case "app_start_dry_towel":
return [method:"service.start_dock_task", params:[dock_task_type:3]]
case "set_fan_speed":
case "set_custom_mode":
def windVal = params instanceof List ? params[0] : params
return [method:"prop.set", params:[wind:windVal]]
case "set_water_box_custom_mode":
def waterVal = params instanceof List ? params[0] : params
return [method:"prop.set", params:[water:waterVal]]
case "set_mop_mode":
def modeVal = params instanceof List ? params[0] : params
return [method:"prop.set", params:[mode:modeVal]]
case "app_segment_clean":
def segs = params instanceof List ? params[0] : params
return [method:"service.segment_clean", params:[segments:segs]]
default:
// Pass through unknown methods as-is
def p = params instanceof List ? params : (params ? [params] : [])
return [method:method, params:p]
}
}
Integer publish(String deviceId, method, params, Integer id) {
logDebug "executing 'publish($deviceId, $method, $params)'"
Integer timestamp = (Integer)(now() / 1000)
Integer protocol = 101
String pv = getAllDevices()?.find{ it.duid?.toString() == deviceId }?.pv ?: "1.0"
String payload
if (pv == "B01") {
// B01 devices use DPS "10000" with translated method names (not DPS "101" with RPC method names)
// Method translation table from ioBroker.roborock B01ControlService:
Map inner = [id:id, msgId:String.valueOf(id)]
Map translated = translateB01Method(method, params)
inner.method = translated.method
inner.params = translated.params
payload = JsonOutput.toJson([dps:["10000": inner], t:timestamp])
} else {
Map inner = [id:id, method:method, params:params]
payload = JsonOutput.toJson( [t:timestamp, dps:["$protocol": JsonOutput.toJson(inner)]] )
}
logWarn "DIAG publish plaintext: ${payload}"
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}"
String hexMsg = message.encodeHex().toString()
logDebug "publishing topic:'$topic'"
logWarn "DIAG publish plaintext: ${payload}"
logDebug "DIAG publish hex[${hexMsg.length()/2}B]: localKey=${getLocalKey(deviceId)} hex=${hexMsg}"
interfaces.mqtt.publish(topic, hexMsg)
return id
}
// Direct DPS write for B01 devices that use DPS 201-206 for action commands instead of RPC
void publishDps(String deviceId, Map dpsData) {
logInfo "executing 'publishDps($deviceId, $dpsData)'"
Integer timestamp = (Integer)(now() / 1000)
Integer protocol = 101
String payload = JsonOutput.toJson( [t:timestamp, dps:dpsData] )
logWarn "DIAG publishDps plaintext: ${payload}"
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}"
String hexMsg = message.encodeHex().toString()
logDebug "publishDps hex[${hexMsg.length()/2}B]: localKey=${getLocalKey(deviceId)} hex=${hexMsg}"
interfaces.mqtt.publish(topic, hexMsg)
}
byte[] build(String deviceId, Integer protocol, Integer timestamp, byte[] payload) {
String localKey = getLocalKey(deviceId)
// B01 protocol uses a per-message nonce: nonce(16 bytes) prepended to encrypted payload
// key = md5(nonce_hex + localKey + salt)
String pv = getAllDevices()?.find{ it.duid?.toString() == deviceId }?.pv ?: "1.0"
// Generate randomInt first - needed for B01 IV derivation AND for the header field
Random random = new Random()
Integer randomInt = random.nextInt(0x7FFFFFFF) + 1
byte[] encrypted
if (pv == "B01") {
// B01: AES-128-CBC
// IV = md5(randomHex8 + B01_SALT)[9:25] using the header random uint32
// This matches the @functor/roborock and ioBroker.roborock implementations
byte[] aesKey = localKey.getBytes("UTF-8")
String ivHex = md5hex(String.format("%08x", randomInt & 0xFFFFFFFFL) + B01_SALT).substring(9, 25)
byte[] iv = ivHex.getBytes("UTF-8")
javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(iv)
Cipher cbcEnc = Cipher.getInstance("AES/CBC/PKCS5Padding")
cbcEnc.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(aesKey, "AES"), ivSpec)
encrypted = cbcEnc.doFinal(payload)
logDebug "build: B01 randomInt=${randomInt}, iv=${ivHex}, encryptedLen=${encrypted.length}"
} else {
String encryptKey = encodeTimestamp(timestamp) + localKey + salt
encrypted = encrypt(payload, encryptKey)
}
int payloadLen = encrypted.length
int totalLength = 23 + payloadLen
byte[] msg = new byte[totalLength]
// Writing fixed string '1.0' or 'B01'
msg[0] = 66 // ASCII for 'B'
msg[1] = 48 // ASCII for '0'
msg[2] = 49 // ASCII for '1'
writeInt32BE(msg, (Integer)(state.sequence & 0xFFFFFFFF), 3)
writeInt32BE(msg, (Integer)(randomInt & 0xFFFFFFFF), 7)
writeInt32BE(msg, timestamp, 11)
writeInt16BE(msg, protocol, 15)
writeInt16BE(msg, payloadLen, 17)
// Write payload: nonce + encrypted for B01, just encrypted for 1.0
int offset = 19
// B01: no nonce prepended; payload is encrypted bytes only
for (Integer i = 0; i < encrypted.length; i++) {
msg[offset + i] = encrypted[i]
}
Integer crc32 = CRC32(msg, msg.length - 4)
writeInt32BE(msg, crc32, msg.length - 4)
return msg
}
// Safe byte array slice - avoids Groovy's range operator returning ArrayList instead of byte[]
byte[] copyBytes(byte[] src, int from, int to) {
int len = to - from
byte[] dst = new byte[len]
for (int i = 0; i < len; i++) dst[i] = src[from + i]
return dst
}
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 mergeUserDevicesIntoHomeData(List userDevices) {
synchronized (this) {
Map existing = g_mGetHomeData[device.getIdAsLong()] ?: [result:[devices:[], products:[], rooms:[], receivedDevices:[]]]
if (existing?.result == null) existing.result = [devices:[], products:[], rooms:[], receivedDevices:[]]
if (existing.result.devices == null) existing.result.devices = []
List existingDuids = existing.result.devices.collect { it.duid }
List newDevices = userDevices.findAll { it.duid && !existingDuids.contains(it.duid) }
if (newDevices.size() > 0) {
logDebug "mergeUserDevices: adding ${newDevices.size()} device(s): ${newDevices.collect{[duid:it.duid, name:it.name]}}"
existing.result.devices.addAll(newDevices)
storeJsonState("homeData", datetimestring(), existing)
g_mGetHomeData[device.getIdAsLong()] = existing
// Trigger home data callback now that devices are available
getHomeDataCallback()
// getDeviceScenes()
} else {
logDebug "mergeUserDevices: all ${userDevices.size()} device(s) already present"
}
}
}
void getUserDevices() {
if (settings?.cloudOnlyMode) { logDebug "getUserDevices: skipped (cloudOnlyMode)"; return }
// Q10 S5+ fix: fetch devices via multiple usiot/IoT endpoints.
try {
Map loginData = getLoginData()
String base = state?.base ?: settings?.regionUri ?: "https://usiot.roborock.com"
String token = loginData?.token
Map homeDetail = getHomeDetailData()
String homeId = homeDetail?.id?.toString()
String rrHomeId = homeDetail?.rrHomeId?.toString()
String uname = settings?.username ?: ""
String headerClientId = uname ? md5hex(uname).bytes.encodeBase64().toString() : ""
logDebug "getUserDevices() homeId=${homeId} rrHomeId=${rrHomeId} base=${base} token=${token?.take(20)}..."
// Endpoint 1: get devices by homeId on the usiot server
if (homeId) {
Map p1 = [
uri: base,
path: "/api/v1/getDevByHomeId",
queryString: "homeId=${homeId}",
headers: [ 'header_clientid': headerClientId, 'Authorization': token ]
]
logDebug "getUserDevices() trying getDevByHomeId homeId=${homeId}"
try { asynchttpGet("asyncHttpCallback", p1, [method:"getUserDevices", variant:"getDevByHomeId"]) }
catch (e) { logWarn "getUserDevices getDevByHomeId error: $e" }
}
// Endpoint 2: get all devices for account on usiot - no params needed
Map p2 = [
uri: base,
path: "/api/v1/getDevByAccount",
headers: [ 'header_clientid': headerClientId, 'Authorization': token ]
]
logDebug "getUserDevices() trying getDevByAccount"
try { asynchttpGet("asyncHttpCallback", p2, [method:"getUserDevices", variant:"getDevByAccount"]) }
catch (e) { logWarn "getUserDevices getDevByAccount error: $e" }
// Endpoint 3: IoT API sub-path for devices within a home (Hawk auth)
Map rriot = loginData?.rriot
if (rriot && rrHomeId) {
String path3 = "/v2/user/homes/${rrHomeId}/devices"
Map p3 = [
uri: rriot?.r?.a,
path: path3,
headers: [ 'Authorization': getHawkAuthentication(rriot?.u, rriot?.s, rriot?.h, path3) ]
]
logDebug "getUserDevices() trying ${rriot?.r?.a}${path3}"
try { asynchttpGet("asyncHttpCallback", p3, [method:"getUserDevicesIot"]) }
catch (e) { logWarn "getUserDevices IoT error: $e" }
}
} catch (e) {
logWarn "getUserDevices() crashed: $e"
}
}
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() {
// Q10 S5+ fix: the Roborock API has two different home IDs in getHomeDetail:
// rrHomeId (e.g. 5126112) - the IoT home ID used by the API
// id (e.g. 5115782) - the user-facing home ID
// CONFIRMED via Homey library traffic capture: the working call is /v3/user/homes/{rrHomeId}
// We try /v3/ first, then fall back to /v2/ and /user/ variants.
Map homeDetail = getHomeDetailData()
String rrHomeId = homeDetail?.rrHomeId?.toString()
String homeId = homeDetail?.id?.toString()
// Build ordered candidate list: /v3/ first (confirmed working), then legacy paths
List candidates = []
if (rrHomeId) candidates << [path:"/v3/user/homes/$rrHomeId", id:rrHomeId]
if (homeId && homeId != rrHomeId) candidates << [path:"/v3/user/homes/$homeId", id:homeId]
if (homeId && homeId != rrHomeId) candidates << [path:"/v2/user/homes/$homeId", id:homeId]
if (rrHomeId) candidates << [path:"/v2/user/homes/$rrHomeId", id:rrHomeId]
if (homeId && homeId != rrHomeId) candidates << [path:"/user/homes/$homeId", id:homeId]
if (rrHomeId) candidates << [path:"/user/homes/$rrHomeId", id:rrHomeId]
// Fallback: if homeDetail cache is empty (e.g. after forceReconnect), use last known home ID from state
if (candidates.isEmpty()) {
String lastKnownHomeId = state?.lastKnownHomeId?.toString()
if (lastKnownHomeId) {
logWarn "getHomeData: homeDetail cache empty, using lastKnownHomeId=${lastKnownHomeId}"
candidates << [path:"/v3/user/homes/${lastKnownHomeId}", id:lastKnownHomeId]
} else {
logWarn "getHomeData: no home candidates and no lastKnownHomeId - calling getHomeDetail() first"
getHomeDetail()
return
}
}
state.homeSearchCandidates = candidates.drop(1).collect { [path:it.path, id:it.id] }
logDebug "getHomeData() trying ${candidates.size()} home candidate(s), first: ${candidates[0]}"
getHomeDataByPath(candidates[0].path)
}
// Fetch home data using an explicit path
void getHomeDataByPath(String path) {
Map rriot = getLoginData()?.rriot
Map params = [
uri: rriot?.r?.a,
path: path,
headers: [ 'Authorization': getHawkAuthentication(rriot?.u, rriot?.s, rriot?.h, path) ]
]
logDebug "getHomeDataByPath() GET ${rriot?.r?.a}${path}"
try {
asynchttpGet("asyncHttpCallback", params, [method: "getHomeData", store: "homeData", params:params])
} catch (e) {
logWarn "'getHomeDataByPath()' asynchttpGet() error: $e"
}
}
// Legacy method kept for compatibility
void getHomeDataById(String homeId) { getHomeDataByPath("/v2/user/homes/$homeId") }
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}"
if (!resp.data) { logWarn "asyncHttpCallback() ${data?.method} [${data?.variant}] status:${resp.status} - no body - headers:${resp.headers}"; return }
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
logDebug "getHomeDetail: rrHomeId=${respJson?.data?.rrHomeId} id=${respJson?.data?.id}"
getHomeData()
// Q10 S5+: skip usiot/IoT calls in cloud-only mode - these trigger local-mode on cloud-only robots
if (settings?.cloudOnlyMode) {
logDebug "getHomeDetail: cloudOnlyMode=true, skipping usiot device lookup"
break
}
// Q10 S5+: inline device lookup - separate getUserDevices() function crashes silently
// due to Hubitat sandbox restrictions on calling helper functions from async context.
// Run the device endpoint calls directly here instead.
String gud_homeId = respJson?.data?.id?.toString()
String gud_rrHomeId = respJson?.data?.rrHomeId?.toString()
String gud_base = state?.base ?: settings?.regionUri
String gud_token = getLoginData()?.token
String gud_clientId = md5hex(settings?.username ?: "").bytes.encodeBase64().toString()
Map gud_rriot = getLoginData()?.rriot
logDebug "Q10 device lookup: homeId=${gud_homeId} rrHomeId=${gud_rrHomeId}"
if (gud_homeId) {
Map gp1 = [ uri: gud_base, path: "/api/v1/getDevByHomeId",
queryString: "homeId=${gud_homeId}",
headers: [ 'header_clientid': gud_clientId, 'Authorization': gud_token ] ]
try { asynchttpGet("asyncHttpCallback", gp1, [method:"getUserDevices", variant:"getDevByHomeId"]) }
catch (e2) { logWarn "getDevByHomeId error: $e2" }
}
Map gp2 = [ uri: gud_base, path: "/api/v1/getDevByAccount",
headers: [ 'header_clientid': gud_clientId, 'Authorization': gud_token ] ]
try { asynchttpGet("asyncHttpCallback", gp2, [method:"getUserDevices", variant:"getDevByAccount"]) }
catch (e2) { logWarn "getDevByAccount error: $e2" }
if (gud_rriot && gud_rrHomeId) {
String gp3path = "/v2/user/homes/${gud_rrHomeId}/devices"
Map gp3 = [ uri: gud_rriot?.r?.a, path: gp3path,
headers: [ 'Authorization': getHawkAuthentication(gud_rriot?.u, gud_rriot?.s, gud_rriot?.h, gp3path) ] ]
try { asynchttpGet("asyncHttpCallback", gp3, [method:"getUserDevicesIot"]) }
catch (e2) { logWarn "getUserDevicesIot error: $e2" }
}
break
// getHomeList case removed - /v2/user/homes (no ID) returns 400
case "getUserDevices":
// Q10 S5+: usiot device lookup - response may be in .data (list or map)
List userDevices = (respJson?.data instanceof List) ? respJson?.data :
(respJson?.data?.devices instanceof List) ? respJson?.data?.devices : []
logDebug "getUserDevices [${data?.variant}]: code=${respJson?.code} msg=${respJson?.msg} devices=${userDevices.size()}"
if (userDevices.size() > 0) {
mergeUserDevicesIntoHomeData(userDevices)
} else {
logWarn "getUserDevices [${data?.variant}]: no devices - full response: ${respJson}"
}
break
case "getUserDevicesWood":
// Q10 S5+: wood server home data response
// The wood server returns the SAME structure as api-us.roborock.com /v2/user/homes/{id}
// but may actually have devices populated
List woodDevices = (respJson?.result?.devices instanceof List) ? respJson?.result?.devices :
(respJson?.result instanceof List) ? respJson?.result :
(respJson?.data instanceof List) ? respJson?.data : []
logDebug "getUserDevicesWood [${data?.variant}]: devices=${woodDevices.size()} keys=${respJson?.keySet()}"
logWarn "getUserDevicesWood [${data?.variant}] full response: ${respJson}"
if (woodDevices.size() > 0) { mergeUserDevicesIntoHomeData(woodDevices) }
break
case "getUserDevicesIot":
// Q10 S5+: IoT API /v2/user/homes/{id}/devices
List iotDevices = (respJson?.result instanceof List) ? respJson?.result :
(respJson?.result?.devices instanceof List) ? respJson?.result?.devices :
(respJson?.data instanceof List) ? respJson?.data : []
logDebug "getUserDevicesIot: code=${respJson?.code} status=${respJson?.status} devices=${iotDevices.size()}"
if (iotDevices.size() > 0) {
mergeUserDevicesIntoHomeData(iotDevices)
} else {
logWarn "getUserDevicesIot: no devs - full: ${respJson}"
}
break
case "getHomeData":
Integer devCount = (respJson?.result?.devices?.size() ?: 0) + (respJson?.result?.receivedDevices?.size() ?: 0)
if (devCount == 0 && state.homeSearchCandidates?.size() > 0) {
// This path returned an empty home - try the next candidate path
Map next = state.homeSearchCandidates.remove(0)
logDebug "getHomeData: home ${respJson?.result?.id} empty, trying next candidate: ${next.path}"
if (next.path.contains("/v3/")) logWarn "getHomeData v3 empty response: ${respJson}"
getHomeDataByPath(next.path)
break
}
state.remove("homeSearchCandidates")
if (devCount == 0) {
logWarn "getHomeData: all home candidates exhausted - trying direct device lookup endpoints"
// Q10 S5+ fix: devices[] is empty in all home responses.
// Try usiot server endpoints that return devices by homeId or account.
Map gld = getLoginData()
Map ghd = getHomeDetailData()
String glBase = state?.base ?: settings?.regionUri
String glToken = gld?.token
String glHdrId = md5hex(settings?.username ?: "x").bytes.encodeBase64().toString()
String glHid = ghd?.id?.toString()
String glRrHid = ghd?.rrHomeId?.toString()
logDebug "direct device lookup: homeId=${glHid} rrHomeId=${glRrHid} base=${glBase}"
logWarn "getHomeDetail raw: ${ghd}"
// Try GET /v3/user/homes (list ALL homes) - robot may be in a different home
Map dlpList = [ uri: gld?.rriot?.r?.a, path: "/v3/user/homes",
headers: [ 'Authorization': getHawkAuthentication(gld?.rriot?.u, gld?.rriot?.s, gld?.rriot?.h, "/v3/user/homes") ] ]
try { asynchttpGet("asyncHttpCallback", dlpList, [method:"getUserDevicesWood", variant:"v3-home-list"]) }
catch (e2) { logWarn "v3 home list error: $e2" }
if (glHid) {
Map dlp1 = [ uri: glBase, path: "/api/v1/getDevByHomeId",
queryString: "homeId=${glHid}",
headers: [ 'header_clientid': glHdrId, 'Authorization': glToken ] ]
try { asynchttpGet("asyncHttpCallback", dlp1, [method:"getUserDevices", variant:"getDevByHomeId"]) }
catch (e2) { logWarn "getDevByHomeId error: $e2" }
}
// getDevByAccount needs a param - try uid and accountId
String glUid = getLoginData()?.uid?.toString()
Map dlp2 = [ uri: glBase, path: "/api/v1/getDevByAccount",
queryString: "uid=${glUid}",
headers: [ 'header_clientid': glHdrId, 'Authorization': glToken ] ]
try { asynchttpGet("asyncHttpCallback", dlp2, [method:"getUserDevices", variant:"getDevByAccount-uid"]) }
catch (e2) { logWarn "getDevByAccount error: $e2" }
// Also try with accountId param
Map dlp2b = [ uri: glBase, path: "/api/v1/getDevByAccount",
queryString: "accountId=${glUid}",
headers: [ 'header_clientid': glHdrId, 'Authorization': glToken ] ]
try { asynchttpGet("asyncHttpCallback", dlp2b, [method:"getUserDevices", variant:"getDevByAccount-accountId"]) }
catch (e2) { logWarn "getDevByAccount-accountId error: $e2" }
if (gld?.rriot && glRrHid) {
// Try the "wood" server (r.l) which newer Roborock accounts use for device data
String woodBase = gld?.rriot?.r?.l
if (woodBase) {
String dlPath3w = "/v2/user/homes/${glRrHid}"
Map dlp3w = [ uri: woodBase, path: dlPath3w,
headers: [ 'Authorization': getHawkAuthentication(gld?.rriot?.u, gld?.rriot?.s, gld?.rriot?.h, dlPath3w) ] ]
logDebug "direct device lookup: trying wood server ${woodBase}${dlPath3w}"
try { asynchttpGet("asyncHttpCallback", dlp3w, [method:"getUserDevicesWood", variant:"wood-rrHomeId"]) }
catch (e2) { logWarn "wood server error: $e2" }
}
// Also try IoT API sub-path
String dlPath3 = "/v2/user/homes/${glRrHid}/devices"
Map dlp3 = [ uri: gld?.rriot?.r?.a, path: dlPath3,
headers: [ 'Authorization': getHawkAuthentication(gld?.rriot?.u, gld?.rriot?.s, gld?.rriot?.h, dlPath3) ] ]
try { asynchttpGet("asyncHttpCallback", dlp3, [method:"getUserDevicesIot"]) }
catch (e2) { logWarn "getUserDevicesIot error: $e2" }
}
}
synchronized (this) {
storeJsonState( data?.store, datetimestring(), respJson )
g_mGetHomeData[device.getIdAsLong()]?.clear()
g_mGetHomeData[device.getIdAsLong()] = respJson
}
logDebug "getHomeData: settled on home ${respJson?.result?.id} with ${devCount} device(s)"
if (respJson?.result?.id) state.lastKnownHomeId = respJson.result.id.toString()
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 {
// If a getHomeData candidate path returned an error (e.g. 400/404), try the next candidate
if (data?.method == "getHomeData" && state.homeSearchCandidates?.size() > 0) {
Map next = state.homeSearchCandidates.remove(0)
logDebug "getHomeData: path ${data?.params?.path} returned ${resp.status}, trying next: ${next.path}"
getHomeDataByPath(next.path)
} else if ((data?.method == "getUserDevices" || data?.method == "getUserDevicesIot") && resp.status != 200) {
logWarn "getUserDevices ${data?.method} returned ${resp.status} - full response: ${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()} ]${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 = getAllDevices()?.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) {
// Q10 S5+: manual localKey override takes priority
if (settings?.manualLocalKey) return settings.manualLocalKey
return getAllDevices()?.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 ?: [:]
}
// Q10 S5+ fix: newer devices may appear under receivedDevices (shared/received) rather than devices.
// Always search both lists so the robot is discovered regardless of how it is registered.
List getAllDevices() {
Map home = getHomeDataResult()
List owned = home?.devices ?: []
List received = home?.receivedDevices ?: []
return (owned + received).unique { it.duid }
}
// 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,105 ]
@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",
// B01-specific state codes (Q10 S5+ and newer devices)
// These are observed from real device behavior - not documented in standard protocol
101: "Starting", // briefly appears when initiating a clean cycle
102: "Vacuuming", // active cleaning (global or room)
104: "Emptying Dust Bin", // auto-empty in progress (dock task)
105: "Idle at Dock", // docked, bin emptied, ready
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",
// Standard dock errors
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",
// Q10 S5+ / auto-empty dock additional errors
35: "Dust Bag Full",
36: "Self-Clean Module Abnormal",
37: "Self-Clean Tank Clogged",
45: "Clean Water Tank Missing",
47: "Waste Water Tank Missing",
48: "Mop Cleaning Module Not Installed",
49: "Mop Drying Module Not Installed",
50: "Check Clean Water Tank Water Level",
51: "Waste Water Tank Needs Emptying",
52: "Clean Water Tank Overflow",
54: "Mop Pad Not Installed",
55: "Dock Cover 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}" }