/** * 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()} ]""" } 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}" }