/**
* Wyze Vacuum Connect App
*
* 1.31.1 - Brian Wilson / bubba@bubba.org
*
* Native Hubitat integration for the Wyze Robot Vacuum (e.g. 200S / JA_RO2).
*
* Wyze has no official public API for this device, so this app speaks the same
* private/reverse-engineered app API used by the open-source wyze-sdk (Python),
* homebridge-wyze-robovac, and matterbridge-wyze-robovac projects. That means:
* - It can break without notice if Wyze changes their backend.
* - It is not affiliated with or supported by Wyze Labs in any way.
* - You need a personal API key/key ID pair from developer-api-console.wyze.com
* (free) in addition to your normal Wyze account email/password.
*
* Setup:
* 1. Create a key at https://developer-api-console.wyze.com/#/apikey/view
* (this gives you a Key Id and an API Key)
* 2. Install this app and the "Wyze Robot Vacuum Driver", enter your Wyze email,
* password, Key Id, and API Key, and click "Log In"
* 3. If prompted, enter your 2FA verification code
* 4. Click "Discover Vacuums", select your vacuum(s), set a poll interval, Done
* 5. Per vacuum: click "Discover Rooms" (requires an active map in the Wyze app),
* pick which rooms to rotate through, and a rotation mode. The driver's
* cleanNextRooms() command then cleans whichever selected rooms have gone
* longest without a clean — wire it to a "everyone left" automation to work
* through the house over the course of a week.
* 6. Optionally pick notification devices and enable start/finish/stuck/bin
* alerts, and set an hours-of-cleaning threshold per vacuum for bin-empty
* reminders.
*
* A room only counts as "cleaned" toward rotation once its run actually
* finishes — if a room-scoped clean is interrupted partway through, whichever
* rooms didn't get their full estimated time stay eligible and are picked
* again next time, rather than being skipped for a whole cycle.
*
* 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
*/
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import groovy.transform.Field
import java.security.MessageDigest
import java.util.zip.Inflater
@Field static final String AUTH_BASE = "https://auth-prod.api.wyze.com"
@Field static final String API_BASE = "https://api.wyzecam.com"
@Field static final String VENUS_BASE = "https://wyze-venus-service-vn.wyzecam.com"
@Field static final String VENUS_APP_ID = "venp_4c30f812828de875"
@Field static final String VENUS_SALT = "CVCSNoa0ALsNEpgKls6ybVTVOmGzFoiq"
@Field static final String APP_VERSION = "2.19.14"
@Field static final String WYZE_SC = "a626948714654991afd3c0dbd7cdb901"
// Long-standing shared "app" key used by the open-source Wyze client ecosystem
// (wyze-sdk, wyze-node, etc.) to reach the auth-prod login endpoint. It is not a
// secret tied to any individual account. Wyze can rotate/revoke it at any time.
@Field static final String WYZE_X_API_KEY = "RckMFKbsds5p6QY3COEXc2ABwNTYY0q18ziEiSEm"
@Field static final String VACUUM_PRODUCT_MODEL = "JA_RO2"
// How full counts as "as charged as it's going to get" -- used only for a room
// whose estimated need exceeds a full charge, so it gets started at the best
// moment available instead of being skipped forever (see roomsBatteryCanCover).
@Field static final Integer NEARLY_FULL_BATTERY_PCT = 95
definition(
name: "Wyze Vacuum Connect",
namespace: "brianwilson-hubitat",
author: "bubba@bubba.org",
description: "Native integration for Wyze Robot Vacuums (unofficial API)",
category: "My Apps",
importUrl: "https://raw.githubusercontent.com/bdwilson/hubitat/master/Wyze-Vacuum/Wyze-Vacuum-App.groovy",
iconUrl: "",
iconX2Url: "",
singleInstance: true
)
preferences {
page(name: "mainPage")
}
// =================== Page ===================
def mainPage() {
def loggedIn = state.wyzeAccessToken != null
def canInstall = loggedIn && settings.selectedVacuums
return dynamicPage(name: "mainPage", install: canInstall, uninstall: true) {
section("Step 1 — Wyze Developer API Key") {
paragraph "Create a free key at developer-api-console.wyze.com " +
"and enter the Key Id / API Key it gives you below."
input "wyzeKeyId", "text", title: "Key Id", required: true, submitOnChange: true
input "wyzeApiKey", "text", title: "API Key", required: true, submitOnChange: true
}
section("Step 2 — Wyze Account") {
input "wyzeEmail", "text", title: "Wyze Email", required: true, submitOnChange: true
input "wyzePassword", "password", title: "Wyze Password", required: true, submitOnChange: true
input "btnLogin", "button", title: loggedIn ? "Re-login" : "Log In", width: 3
if (state.wyzeLoginError) paragraph "${state.wyzeLoginError}"
if (state.wyzeMfa) {
paragraph "Two-factor authentication required. Enter the code Wyze sent/your authenticator app shows."
input "mfaCode", "text", title: "Verification Code", required: true, submitOnChange: true
input "btnSubmitMfa", "button", title: "Submit Code", width: 3
} else if (loggedIn) {
paragraph "✓ Logged in to Wyze"
}
}
if (loggedIn) {
section("Step 3 — Devices") {
input "btnDiscover", "button", title: "Discover Vacuums", width: 3
if (state.discoveryError) paragraph "${state.discoveryError}"
if (state.discoveredVacuums) {
input "selectedVacuums", "enum",
title: "Select Vacuum(s)",
options: state.discoveredVacuums,
multiple: true, required: true, submitOnChange: true
}
}
if (settings.selectedVacuums) {
section("Polling") {
paragraph "Uses a faster interval while any selected vacuum is actively cleaning, and a slower one the rest of the time, so status stays responsive during a run without polling needlessly while idle/charging."
input "pollIntervalCleaning", "enum",
title: "Poll interval while cleaning",
options: ["1": "Every 1 min", "2": "Every 2 min", "5": "Every 5 min"],
defaultValue: "1", required: true
input "pollIntervalIdle", "enum",
title: "Poll interval while idle/charging",
options: ["5": "Every 5 min", "10": "Every 10 min", "15": "Every 15 min", "30": "Every 30 min"],
defaultValue: "15", required: true
}
section("Notifications") {
paragraph "Notifications are change-driven — polling by itself never triggers one."
input "notifyDevices", "capability.notification", title: "Send notifications to", multiple: true, required: false, submitOnChange: true
if (settings.notifyDevices) {
input "notifyCleaningStarted", "bool", title: "Notify when cleaning starts", defaultValue: true, required: false
input "notifyCleaningFinished", "bool", title: "Notify when cleaning finishes", defaultValue: true, required: false
input "notifyStuck", "bool", title: "Notify when the vacuum reports a fault", defaultValue: true, required: false
}
input "ignoredFaultCodes", "text",
title: "Fault codes to treat as normal, not real faults (comma-separated) — e.g. some codes may just mean \"charging\"/\"fully charged\", not an actual problem",
defaultValue: "2102,2103,2105", required: false
}
settings.selectedVacuums.each { mac ->
def vacLabel = state.discoveredVacuums?.get(mac) ?: mac
def roomErr = state["roomError_${mac}"]
section("${vacLabel} — Room Rotation") {
input "btnDiscoverRooms_${mac}", "button", title: "Discover Rooms", width: 3
if (roomErr) paragraph "${roomErr}"
def rooms = state.discoveredRooms?.getAt(mac)
if (rooms) {
def roomOptions = rooms.collectEntries { [(it.id.toString()): it.name] }
input "rotationRooms_${mac}", "enum",
title: "Rooms to include in rotation",
options: roomOptions, multiple: true, required: false, submitOnChange: true
if (settings["rotationRooms_${mac}"]) {
input "rotationMode_${mac}", "enum",
title: "Rotation mode",
options: ["count": "Fixed number of rooms per run", "time": "Time budget per run"],
defaultValue: "count", required: true, submitOnChange: true
if ((settings["rotationMode_${mac}"] ?: "count") == "time") {
input "rotationMinutes_${mac}", "number", title: "Target minutes per run", defaultValue: 30, required: true
} else {
// Default is 1, not more, on purpose: a single-room dispatch is always
// ground truth for that room's clean time (see finishActiveCleanRun --
// nothing to split, so the real elapsed time overwrites the estimate
// outright). Since each run always picks whichever room is most overdue,
// rotation naturally cycles through every room in turn -- keep this at 1
// until every room has been cleaned at least once and you've built up a
// real timing corpus, then raise it if you want faster multi-room runs.
input "rotationCount_${mac}", "number", title: "Rooms per run", defaultValue: 1, required: true
paragraph "Tip: leave this at 1 until every rotation room has been cleaned at least once -- each single-room " +
"run directly measures that room's real clean time. Raise it later once you have real timings for everything."
}
input "rotationCycleDays_${mac}", "number",
title: "Cycle length (days) for normal-traffic rooms — a room becomes eligible again after this many days, even if already cleaned this cycle",
defaultValue: 7, required: true
def selectedIds = (settings["rotationRooms_${mac}"] ?: []).collect { it as Integer }
def selectedRoomOptions = rooms.findAll { (it.id as Integer) in selectedIds }.collectEntries { [(it.id.toString()): it.name] }
input "highTrafficRooms_${mac}", "enum",
title: "High-traffic rooms — get their own shorter cycle (set once you've picked some) and are prioritized over normal-traffic rooms once due",
options: selectedRoomOptions, multiple: true, required: false, submitOnChange: true
if (settings["highTrafficRooms_${mac}"]) {
input "rotationCycleDaysHighTraffic_${mac}", "number",
title: "Cycle length (days) for high-traffic rooms — e.g. 3 for roughly twice a week",
defaultValue: 3, required: true, submitOnChange: true
def highDays = (settings["rotationCycleDaysHighTraffic_${mac}"] ?: 3) as Double
if (highDays > 0) {
paragraph "Currently this is about ${String.format('%.1f', 7.0 / highDays)} times a week, based on the number you've entered."
}
}
input "rotationContinuousMode_${mac}", "bool",
// Deliberately not "the time limit below" -- that input only exists while this
// toggle is on, so with it off the label pointed at nothing.
title: "Keep sweeping continuously while triggered, even once nothing's technically due yet (works through the whole rotation list on a loop; stops when dock()/pause()/off() is called, or after an optional time limit that appears once this is switched on)",
defaultValue: false, required: false, submitOnChange: true
if (settings["rotationContinuousMode_${mac}"]) {
input "rotationContinuousLimitEnabled_${mac}", "bool",
title: "Limit how long continuous sweeping runs before docking",
defaultValue: false, required: false, submitOnChange: true
if (settings["rotationContinuousLimitEnabled_${mac}"]) {
input "rotationContinuousMaxMinutes_${mac}", "number",
title: "Stop continuous sweeping and dock after this many minutes (counted from when the sweep started)",
defaultValue: 60, required: true
}
}
input "requireBatteryForRoom_${mac}", "bool",
title: "Don't start a rotation room unless the battery can cover it — skipped rooms stay due and get picked up on the next trigger",
defaultValue: true, required: false, submitOnChange: true
if (settings["requireBatteryForRoom_${mac}"] != false) {
def nextUp = previewNextRooms(mac)
def drain = batteryDrainPerMin(mac)
def learned = state.batteryDrainPerMin?.getAt(mac) != null
def detail = nextUp
? "Next up is ${nextUp.collect { it.name }.join(', ')}, needing about ${batteryNeededFor(mac, nextUp.collect { it.id as Integer })}% " +
"(battery is currently ${getChildDevice(mac)?.currentValue('battery') ?: '?'}%)."
: "Nothing is queued right now."
// Rooms too big to finish on one charge are worth calling out -- they
// behave differently (started near-full and finished after a recharge)
// and it's the clearest signal that a room wants splitting in the Wyze app.
def bigRooms = (settings["rotationRooms_${mac}"] ?: []).collect { it as Integer }.findAll {
batteryNeededFor(mac, [it]) > 100
}
def bigNote = bigRooms
? " ${roomNamesFor(mac, bigRooms).join(', ')} needs more than a full charge, so it's started once the battery is at least " +
"${NEARLY_FULL_BATTERY_PCT}% and the vacuum charges and resumes partway through. Splitting it into smaller zones in the Wyze app would avoid that."
: ""
paragraph "Uses ${String.format('%.1f', drain)}% of battery per minute of cleaning" +
(learned ? ", measured from this vacuum's own runs" : " (starting estimate — replaced once a few real runs are recorded)") +
", plus a 10% reserve so it isn't finishing right at Wyze's own return-to-dock threshold. ${detail}${bigNote} " +
"This only decides whether to start a rotation room — it never overrides the vacuum's own low-battery return, and " +
"cleanRooms()/room buttons/Learning Mode always run regardless."
}
def pending = pendingRoomCount(mac)
paragraph "${pending} of ${(settings["rotationRooms_${mac}"] ?: []).size()} rotation room(s) are due for cleaning right now."
}
} else {
paragraph "Click Discover Rooms after the vacuum has completed at least one clean and has an active map with named rooms in the Wyze app."
}
}
section("${vacLabel} — Room Timing") {
def learning = state.learningMode?.getAt(mac)
if (learning) {
paragraph "Learning mode running — ${(learning.queue?.size() ?: 0)} more room(s) queued after the current one."
input "btnCancelLearning_${mac}", "button", title: "Cancel Learning", width: 3
} else {
paragraph "Cleans every selected rotation room by itself, one at a time, to directly measure each room's real clean " +
"time (used to drive the \"time budget\" rotation mode) instead of estimating from mixed multi-room runs. " +
"Takes a while — it works through every room in sequence."
input "btnLearnRooms_${mac}", "button", title: "Learn Room Times", width: 3
}
def avg = state.roomAvgMinutes?.getAt(mac)
if (avg) {
def known = state.discoveredRooms?.getAt(mac) ?: []
def lines = avg.collect { k, v -> "${known.find { it.id.toString() == k }?.name ?: "Room ${k}"}: ${String.format('%.1f', (v as Double))} min" }
paragraph "Known room times — ${lines.join(', ')}"
}
def roomsForTiming = state.discoveredRooms?.getAt(mac)
if (roomsForTiming) {
paragraph "Manually set or correct a room's clean-time estimate below — e.g. after reinstalling this app (which resets " +
"learned timing data) so you don't have to re-earn it from scratch, or to fix a number you know is wrong. " +
"Leave a field blank to leave that room's estimate untouched; only fields you fill in get applied. Fields show " +
"the value as of when this page last loaded, not live — reopen the page to see the latest learned numbers."
roomsForTiming.each { room ->
input "roomTimeOverride_${mac}_${room.id}", "decimal",
title: "${room.name} (minutes)",
defaultValue: (avg?.getAt(room.id.toString())), required: false, width: 4
}
input "btnSetRoomTimes_${mac}", "button", title: "Save Room Times", width: 3
}
}
section("${vacLabel} — Room Buttons") {
def rooms = state.discoveredRooms?.getAt(mac)
if (rooms) {
paragraph "Assign rooms to fixed slots. Each slot is its own no-argument command (cleanRoomSlot1() … cleanRoomSlot8()) " +
"on this vacuum's device — add one Dashboard tile per slot (same device, a different command each) for a " +
"one-tap \"clean this room\" button. No typing, no picker, no extra devices."
def slotOptions = ["": "-- not assigned --"] + rooms.collectEntries { [(it.id.toString()): it.name] }
(1..8).each { n ->
input "roomSlot${n}_${mac}", "enum", title: "Slot ${n} room", options: slotOptions, required: false, submitOnChange: true
}
} else {
paragraph "Discover rooms first to assign room buttons."
}
}
section("${vacLabel} — Mark Rooms as Cleaned") {
def rooms3 = state.discoveredRooms?.getAt(mac)
if (rooms3) {
paragraph "Manually corrects rotation history without actually cleaning anything — for a room you cleaned by hand, " +
"or a run whose completion never got recorded, so it stops getting picked first ahead of rooms that are actually more overdue."
def markOptions = rooms3.collectEntries { [(it.id.toString()): it.name] }
input "markCleanRooms_${mac}", "enum", title: "Rooms to mark as cleaned right now", options: markOptions, multiple: true, required: false, submitOnChange: true
input "btnMarkCleaned_${mac}", "button", title: "Mark as Cleaned", width: 3
} else {
paragraph "Discover rooms first."
}
}
section("${vacLabel} — Low Battery Protection") {
paragraph "Wyze's own firmware already returns to charge and resumes on its own at some internal threshold. This is a supplementary, " +
"more conservative trigger you control — sends it back to dock as soon as battery drops below this while actively cleaning."
input "lowBatteryDockPercent_${mac}", "number",
title: "Dock if battery drops below this % while cleaning (0 = disabled, rely on the vacuum's own behavior)",
defaultValue: 0, required: false
paragraph "When the battery runs out mid-job, the vacuum docks itself, charges, and then restarts that job on its own — often " +
"hours later, at whatever time it finishes charging. The job is one this app dispatched, but the restart isn't: the " +
"vacuum decides when, so it can come back long after whatever triggered the original run stopped applying."
input "cancelAutoResume_${mac}", "bool",
title: "Don't let it auto-resume — send it back to the dock if it restarts an unfinished job on its own (the room stays pending for the next rotation)",
defaultValue: false, required: false
}
section("${vacLabel} — Bin Reminder") {
input "emptyBinHours_${mac}", "number",
title: "Notify to empty the bin after this many cumulative cleaning hours (0 = disabled)",
defaultValue: 0, required: false
def hrs = (state.cleaningHoursSinceEmpty?.getAt(mac) ?: 0.0) as Double
paragraph "Cumulative cleaning time since last emptied: ${String.format('%.1f', hrs)} hours"
input "btnResetBin_${mac}", "button", title: "I emptied it — reset", width: 3
input "manualBinHours_${mac}", "decimal", title: "Set cumulative hours to (correct a number you know is off)", required: false, width: 4
input "btnSetBinHours_${mac}", "button", title: "Set Hours", width: 3
}
}
section("Options") {
input "isDebug", "bool", title: "Enable Debug Logging", defaultValue: false, submitOnChange: true
}
}
}
}
}
def appButtonHandler(btn) {
if (btn == "btnLogin") {
state.wyzeLoginError = null
state.wyzeMfa = null
loginWyze()
} else if (btn == "btnSubmitMfa") {
submitMfaCode()
} else if (btn == "btnDiscover") {
state.discoveryError = null
discoverVacuums()
} else if (btn.startsWith("btnDiscoverRooms_")) {
def mac = btn - "btnDiscoverRooms_"
state["roomError_${mac}"] = null
discoverRooms(mac)
} else if (btn.startsWith("btnResetBin_")) {
resetBinTimer(btn - "btnResetBin_")
} else if (btn.startsWith("btnLearnRooms_")) {
startLearningMode(btn - "btnLearnRooms_")
} else if (btn.startsWith("btnCancelLearning_")) {
cancelLearningMode(btn - "btnCancelLearning_")
} else if (btn.startsWith("btnMarkCleaned_")) {
def mac = btn - "btnMarkCleaned_"
def ids = (settings["markCleanRooms_${mac}"] ?: []).collect { it as Integer }
if (ids) {
markRoomsCleaned(mac, ids)
def known = state.discoveredRooms?.getAt(mac) ?: []
def names = ids.collect { id -> known.find { it.id == id }?.name ?: "Room ${id}" }
def d = getChildDevice(mac)
d?.sendEvent(name: "lastCleanedRooms", value: names.join(", "))
if (d) updateRotationPreviewAttributes(d, mac)
}
// Otherwise the picker just sits there looking "selected" forever,
// and a later click of the same button (e.g. after picking a
// *different* set of rooms elsewhere on the page) would silently
// re-mark whatever was left checked here.
app.removeSetting("markCleanRooms_${mac}")
} else if (btn.startsWith("btnSetRoomTimes_")) {
setRoomTimesManually(btn - "btnSetRoomTimes_")
} else if (btn.startsWith("btnSetBinHours_")) {
def mac = btn - "btnSetBinHours_"
def hrs = settings["manualBinHours_${mac}"]
if (hrs != null) {
state.cleaningHoursSinceEmpty = state.cleaningHoursSinceEmpty ?: [:]
state.cleaningHoursSinceEmpty[mac] = (hrs as Double)
getChildDevice(mac)?.sendEvent(name: "hoursSinceEmptied", value: Math.round((hrs as Double) * 10) / 10.0)
ifDebug("btnSetBinHours(${mac}): manually set to ${hrs}h")
}
app.removeSetting("manualBinHours_${mac}")
}
}
// Lets a room's clean-time estimate be set/corrected directly, rather than
// only ever earned back through real cleaning runs -- e.g. after
// reinstalling this app (state.roomAvgMinutes is app-local and doesn't
// survive that) so timing data doesn't have to be re-learned from scratch,
// or to fix a number known to be wrong. Only rooms with a filled-in field
// are touched; anything left blank keeps whatever estimate it already had.
private void setRoomTimesManually(String mac) {
def known = state.discoveredRooms?.getAt(mac) ?: []
if (!known) return
state.roomAvgMinutes = state.roomAvgMinutes ?: [:]
def avgMap = state.roomAvgMinutes[mac] ?: [:]
def updated = []
known.each { room ->
def val = settings["roomTimeOverride_${mac}_${room.id}"]
if (val != null) {
avgMap[room.id.toString()] = (val as Double)
updated << room.name
}
}
state.roomAvgMinutes[mac] = avgMap
ifDebug("setRoomTimesManually(${mac}): manually set ${updated}")
}
// =================== Lifecycle ===================
def installed() { updated() }
def uninstalled() {
unschedule()
getAllChildDevices().each { deleteChildDevice(it.deviceNetworkId) }
}
def updated() {
// Named, not bare unschedule() -- see rescheduleDynamicPoll() for why:
// a bare unschedule() cancels every pending one-shot job for this app,
// including an in-flight rotation-sweep continuation (runIn(5,
// "continueSweepDispatch", ...)) if settings happen to be saved while
// one's pending, silently killing it with no error or explanation.
unschedule("pollAllVacuums")
if (settings.isDebug) runIn(3600, logsOff)
if (state.wyzeAccessToken && settings.selectedVacuums) {
settings.selectedVacuums.each { mac -> ensureChildDevice(mac) }
// remove child devices for macs the user deselected
getAllChildDevices().each { d ->
if (!(d.deviceNetworkId in settings.selectedVacuums)) deleteChildDevice(d.deviceNetworkId)
}
runIn(5, pollAllVacuums)
state.currentPollMode = null // force rescheduleDynamicPoll to (re)schedule below
rescheduleDynamicPoll()
}
}
private String pollCron(String minutes) {
switch (minutes) {
case "1": return "0 * * * * ?"
case "2": return "0 0/2 * * * ?"
case "5": return "0 0/5 * * * ?"
case "10": return "0 0/10 * * * ?"
case "15": return "0 0/15 * * * ?"
case "30": return "0 0/30 * * * ?"
default: return "0 0/5 * * * ?"
}
}
// Switches the scheduled poll's cadence based on whether any selected
// vacuum is currently cleaning -- faster (pollIntervalCleaning) while a
// run is active, slower (pollIntervalIdle) the rest of the time. Only
// actually reschedules when the mode changes, not on every poll.
private void rescheduleDynamicPoll() {
// Also treat "we just dispatched a room-clean and are waiting on the
// first poll to confirm it" as cleaning, not just a confirmed
// lastKnownStatus=="Cleaning" -- otherwise, with the idle interval at its
// default 15 min and single-room dispatches often finishing well inside
// that window, a whole start-to-finish cleaning cycle could land
// entirely between two idle-interval polls and never get caught at all,
// silently skipping both the start/finish notifications and credit.
def anyCleaning = settings.selectedVacuums?.any { mac ->
state.lastKnownStatus?.getAt(mac) == "Cleaning" || state.activeCleanRun?.containsKey(mac) || state.rotationSweepPending?.getAt(mac)
} ?: false
def desiredMode = anyCleaning ? "cleaning" : "idle"
if (state.currentPollMode == desiredMode) return
def minutes = anyCleaning ? (settings.pollIntervalCleaning ?: "1") : (settings.pollIntervalIdle ?: "15")
// Deliberately NOT bare unschedule() -- that cancels *every* pending
// scheduled job for this app instance, not just the recurring poll.
// Confirmed live: a room finishing correctly scheduled the next sweep
// room via runIn(5, "continueSweepDispatch", ...), but this function
// runs right afterward in the same handleVacuumStatusResponse call (now
// that cleaning has ended, polling should slow back down) -- and its
// bare unschedule() wiped out that just-scheduled continuation before it
// ever fired. Named unschedule only touches the poll job.
unschedule("pollAllVacuums")
schedule(pollCron(minutes), pollAllVacuums)
state.currentPollMode = desiredMode
ifDebug("rescheduleDynamicPoll: switched to ${desiredMode} polling (every ${minutes} min)")
}
private void ensureChildDevice(String mac) {
def d = getChildDevice(mac)
if (!d) {
def label = state.discoveredVacuums?.get(mac) ?: "Wyze Vacuum ${mac}"
log.info "Wyze Vacuum: creating child device for ${mac}"
try {
addChildDevice("brianwilson-hubitat", "Wyze Robot Vacuum Driver", mac, null,
[name: "Wyze Vacuum", label: label, completedSetup: true])
} catch (e) {
log.error "Wyze Vacuum: failed to create child device: ${e.message}. Ensure 'Wyze Robot Vacuum Driver' is installed under Drivers Code."
}
}
}
// =================== Login / Auth ===================
private void loginWyze() {
if (!state.wyzePhoneId) state.wyzePhoneId = java.util.UUID.randomUUID().toString()
if (!settings.wyzeEmail || !settings.wyzePassword || !settings.wyzeKeyId || !settings.wyzeApiKey) {
state.wyzeLoginError = "Enter Key Id, API Key, email, and password first."
return
}
def hashedPw = md5Hex(md5Hex(md5Hex(settings.wyzePassword)))
state.wyzePendingHashedPassword = hashedPw
def nonce = now()
def body = JsonOutput.toJson([nonce: "${nonce}", email: settings.wyzeEmail, password: hashedPw])
Map result = null
try {
httpPost([
uri: AUTH_BASE,
path: "/api/user/login",
requestContentType: "application/json",
headers: [
"x-api-key": WYZE_X_API_KEY,
"keyid" : settings.wyzeKeyId,
"apikey" : settings.wyzeApiKey,
"user-agent": "hubitat-wyze-vacuum/1.0",
"Accept-Encoding": "gzip"
],
body: body,
timeout: 30
]) { resp -> result = resp.data instanceof Map ? resp.data : new JsonSlurper().parseText(resp.data.text) }
} catch (groovyx.net.http.HttpResponseException e) {
state.wyzeLoginError = "Login failed (${e.statusCode}): ${e.message}"
log.error "Wyze login failed: ${e.statusCode} ${e.message}"
return
} catch (e) {
state.wyzeLoginError = "Login failed: ${e.message}"
log.error "Wyze login error: ${e}"
return
}
handleLoginResult(result)
}
private void handleLoginResult(Map result) {
if (result?.access_token) {
state.wyzeAccessToken = result.access_token
state.wyzeRefreshToken = result.refresh_token
state.wyzeUserId = result.user_id
state.wyzeMfa = null
state.wyzeLoginError = null
ifDebug("Wyze login successful")
discoverVacuums()
return
}
def mfaOptions = result?.mfa_options
if (mfaOptions && "TotpVerificationCode" in mfaOptions) {
state.wyzeMfa = [
type: "TotpVerificationCode",
verificationId: result.mfa_details?.totp_apps?.getAt(0)?.app_id
]
ifDebug("Wyze login requires TOTP 2FA")
return
}
if (mfaOptions && "PrimaryPhone" in mfaOptions) {
def smsResp = authPost("/user/login/sendSmsCode", [
mfaPhoneType: "Primary",
sessionId: result.sms_session_id,
userId: result.user_id
])
state.wyzeMfa = [
type: "PrimaryPhone",
verificationId: smsResp?.session_id
]
ifDebug("Wyze login requires SMS 2FA")
return
}
state.wyzeLoginError = "Login failed: ${result?.msg ?: result}"
log.error "Wyze login failed: ${result}"
}
private void submitMfaCode() {
if (!state.wyzeMfa || !settings.mfaCode) return
def payload = [
email: settings.wyzeEmail,
password: state.wyzePendingHashedPassword,
mfa_type: state.wyzeMfa.type,
verification_id: state.wyzeMfa.verificationId,
verification_code: settings.mfaCode
]
def result = authPost("/user/login", payload)
handleLoginResult(result)
}
private Map authPost(String path, Map body) {
Map result = null
try {
httpPost([
uri: AUTH_BASE,
path: path,
requestContentType: "application/json",
headers: ["x-api-key": WYZE_X_API_KEY, "Accept-Encoding": "gzip"],
body: JsonOutput.toJson(body),
timeout: 30
]) { resp -> result = resp.data instanceof Map ? resp.data : new JsonSlurper().parseText(resp.data.text) }
} catch (e) {
log.error "Wyze auth POST ${path} failed: ${e}"
return null
}
return result
}
private boolean refreshWyzeToken() {
if (!state.wyzeRefreshToken) return false
ifDebug("Refreshing Wyze access token")
def resp = apiWyzeRequest("/app/user/refresh_token", [refresh_token: state.wyzeRefreshToken, sv: "d91914dd28b7492ab9dd17f7707d35a3"], false)
def data = resp?.data ?: resp
if (data?.access_token) {
state.wyzeAccessToken = data.access_token
if (data.refresh_token) state.wyzeRefreshToken = data.refresh_token
ifDebug("Wyze token refresh succeeded")
return true
}
log.error "Wyze token refresh failed: ${resp}"
return false
}
// =================== Device Discovery ===================
private void discoverVacuums() {
def resp = apiWyzeRequest("/app/v2/home_page/get_object_list", [sv: "c417b62d72ee44bf933054bdca183e77"])
def list = resp?.data?.device_list
if (list == null) {
state.discoveryError = "Could not retrieve device list. Check credentials and logs."
return
}
def found = [:]
list.each { dev ->
if (dev.product_model == VACUUM_PRODUCT_MODEL) {
found[dev.mac] = dev.nickname ?: dev.mac
}
}
if (!found) {
state.discoveryError = "No Wyze vacuums found on this account."
return
}
state.discoveredVacuums = found
state.discoveryError = null
ifDebug("Discovered vacuums: ${found}")
}
// =================== Polling ===================
//
// The scheduled poll uses asynchttpGet exclusively -- Hubitat throttles apps
// that make blocking HTTP calls from a scheduled job ("excessive hub load"),
// which synchronous httpGet/httpPost from a cron-triggered handler reliably
// tripped here. The two Venus reads (properties, status) are independent, so
// they're fired as two separate async calls with their own handlers rather
// than chained -- no need to synchronize their arrival. The Cleaning-session
// end handler reads the device's last-known cleanTime attribute (kept fresh
// by the properties poll throughout a session) instead of requiring a
// simultaneous fresh fetch.
def pollAllVacuums() {
settings.selectedVacuums?.each { mac -> pollVacuum(mac) }
}
def pollVacuum(String mac) {
if (!getChildDevice(mac)) return
pollVacuumProps(mac)
pollVacuumStatus(mac)
}
private void pollVacuumProps(String mac) {
def keys = ["battary", "mode", "cleanlevel", "chargeState", "cleanSize", "cleanTime", "fault_code", "fault_type"]
venusGetAsync("/plugin/venus/get_iot_prop", [did: mac, keys: keys.join(",")], "handleVacuumPropsResponse", [mac: mac])
}
private void pollVacuumStatus(String mac) {
venusGetAsync("/plugin/venus/${mac}/status", [:], "handleVacuumStatusResponse", [mac: mac])
}
def handleVacuumPropsResponse(resp, data) {
def mac = data?.mac
def d = getChildDevice(mac)
if (!d) return
if (handleVenusAsyncError(resp, data, "handleVacuumPropsResponse")) return
def props = parseAsyncJson(resp)?.data?.props
if (props == null) { ifDebug("pollVacuum(${mac}): no props returned"); return }
if (props.battary != null) {
def batteryPct = toInt(props.battary)
d.sendEvent(name: "battery", value: batteryPct, unit: "%")
checkLowBatteryAutoDock(mac, batteryPct)
}
if (props.mode != null) d.sendEvent(name: "mode", value: vacuumModeDescription(props.mode))
if (props.cleanlevel != null) d.sendEvent(name: "suctionLevel", value: suctionLevelName(props.cleanlevel))
if (props.chargeState != null) d.sendEvent(name: "charging", value: (toInt(props.chargeState) == 1) ? "true" : "false")
if (props.cleanSize != null) d.sendEvent(name: "cleanSize", value: toInt(props.cleanSize))
if (props.cleanTime != null) d.sendEvent(name: "cleanTime", value: toInt(props.cleanTime))
updateFaultAttribute(d, mac, props)
// Rotation-preview attributes and lastRefresh are updated only from
// handleVacuumStatusResponse below, not here too -- see that function's
// matching comment for why.
}
def handleVacuumStatusResponse(resp, data) {
def mac = data?.mac
def d = getChildDevice(mac)
if (!d) return
if (handleVenusAsyncError(resp, data, "handleVacuumStatusResponse")) return
def statusData = parseAsyncJson(resp)?.data
// vacuum_work_status was never actually present in this endpoint's
// response -- confirmed live via a raw dump (1.17.1's diagnostic
// warning): heartBeat only has mode/charge_state/battery/etc, the exact
// same shape as the props poll. That's why status looked "frozen": every
// single poll was silently no-op'ing on the old `workStatus == null`
// check before ever reaching the rest of this function. mode is what's
// actually there, and vacuumModeDescription() (below) already has a
// real mapping for it, previously cross-validated live (mode 11 during
// an actual low-battery recharge). Status is now derived from mode +
// charging directly instead of a field Wyze never sends.
// Deliberately NOT `?:` here -- mode:0 ("Idle") is a real, common value,
// and Groovy Truth treats 0 as falsy, so `heartBeat?.mode ?: eventFlag?.mode`
// would silently discard a genuine mode:0 and fall through to eventFlag
// (which doesn't even have a mode key), landing on the "no mode" warning
// below for perfectly valid data. Confirmed live: this happened on every
// single poll while idle. Explicit null checks only.
def modeCode = statusData?.heartBeat?.mode
if (modeCode == null) modeCode = statusData?.eventFlag?.mode
if (modeCode == null) {
log.warn "Wyze Vacuum ${mac}: status poll has no mode either -- heartBeat=${statusData?.heartBeat} eventFlag=${statusData?.eventFlag} raw=${statusData}"
return
}
// Read charge_state from this *same* status-poll payload, not the
// separately-polled "charging" device attribute -- confirmed live those
// two async polls can land moments apart (props updates "charging"
// slightly after or before status's callback runs), producing a
// transient wrong label (mode 0 + a stale charging=false read ->
// "Standby" instead of "Docked") right after the vacuum actually docks.
// Functionally harmless either way (both are non-Cleaning), but reading
// charge_state from the same response as mode removes the race outright.
def chargeStateCode = statusData?.heartBeat?.charge_state
if (chargeStateCode == null) chargeStateCode = statusData?.eventFlag?.charge_state
boolean isCharging = toInt(chargeStateCode) == 1
def newStatus = deriveStatusFromMode(toInt(modeCode), isCharging)
log.info "Wyze Vacuum ${mac} mode=${modeCode} charge_state=${chargeStateCode} -> status=\"${newStatus}\""
// Deliberately NOT d.currentValue("status") -- the driver's own command
// methods (start/pause/dock/cleanNextRooms/etc.) optimistically write
// that attribute themselves for immediate UI feedback, before this poll
// ever runs. Reading it here would mean "previous status" is often
// already overwritten by the very command that caused this transition,
// so the transition would never be detected. Track our own copy instead,
// updated only from confirmed poll data.
state.lastKnownStatus = state.lastKnownStatus ?: [:]
def prevStatus = state.lastKnownStatus[mac]
d.sendEvent(name: "status", value: newStatus)
// Now the real raw mode code status was derived from (see deriveStatusFromMode).
d.sendEvent(name: "workStatusCode", value: toInt(modeCode))
// Keep the Switch capability's "switch" attribute honest against real
// vacuum state, not just the last on()/off() the user tapped -- it flips
// to "off" on its own once a clean actually finishes, gets docked, etc.
// Deliberately NOT just "is it cleaning right now." A job that ran the
// battery down is still a job in progress -- the vacuum is sitting on the
// dock charging and fully intends to pick it back up. Reporting the switch
// as off during that window breaks the common wiring of on = clean next
// rooms / off = dock: the switch is already off when someone gets home, so
// their "turn it off" automation has nothing to turn off, and the pending
// job later resumes anyway. Staying on for as long as work is outstanding
// keeps off() meaningful -- it's what actually cancels the rest of the job.
d.sendEvent(name: "switch", value: (newStatus == "Cleaning" || hasWorkPending(mac)) ? "on" : "off")
if (prevStatus != "Cleaning" && newStatus == "Cleaning") {
// Checked (and cleared) before the message is built -- a vacuum
// coming back from a mode-11 "will resume" pause is continuing the
// job it already started, not beginning a new one.
boolean isResume = consumePausedForResume(mac)
state.commandInterrupt?.remove(mac) // cleaning again -- nothing left to describe as interrupted
state.cleaningSessionStart = state.cleaningSessionStart ?: [:]
state.cleaningSessionStart[mac] = now()
// Paired with the reading at the end of the run to measure how fast
// this vacuum actually drains while cleaning (see learnBatteryDrain).
// Deliberately not recorded for a resumed job: the vacuum charged
// partway through, so the battery delta and the elapsed time being
// compared no longer cover the same stretch of cleaning.
state.cleaningStartBattery = state.cleaningStartBattery ?: [:]
if (isResume) {
state.cleaningStartBattery.remove(mac)
} else {
state.cleaningStartBattery[mac] = toInt(d?.currentValue("battery"))
}
// The firmware picks its own moment to restart a battery-paused job --
// whenever charging happens to finish, which can be hours later and at
// a genuinely unwanted time. Confirmed live: an "everyone left" trigger
// ran cleanNextRooms() at 5:57pm, the sweep advanced to the next room
// at 6:44pm on a 15% battery, that room died at 6% by 6:51pm, and the
// vacuum restarted it itself at 8:35pm -- by which point everyone was
// home again. The job is one this app dispatched; the *restart* is the
// vacuum's own call, which is why a presence-triggered rule has nothing
// to catch (it fired hours earlier, on arrival, with the vacuum already
// parked and charging).
//
// Two things cancel such a restart: the always-on option below, or an
// explicit stop issued while the job was paused (switch off / dock /
// pause). The second one isn't optional -- someone who turned it off
// has already said they don't want it, and the vacuum ignores a dock
// command it's already obeying, so squashing the restart here is the
// only place that intent can actually be enforced.
boolean cancelledByUser = consumeResumeCancelled(mac)
boolean cancellingResume = cancelledByUser || (isResume && (settings["cancelAutoResume_${mac}"] ?: false))
if (cancellingResume) {
// Deferred rather than docking inline: this is an async poll
// callback, and venusControl posts synchronously -- the same
// shape that tripped Hubitat's hub-load guardrail in 1.5.1.
runIn(2, "cancelAutoResumeDock", [data: [mac: mac], overwrite: false])
}
if (settings.notifyCleaningStarted) {
sendVacuumNotification(cleaningStartedMessage(mac, d, isResume, cancellingResume))
}
} else if (prevStatus == "Cleaning" && newStatus != "Cleaning") {
handleCleaningSessionEnd(mac, d.currentValue("cleanTime"), d, newStatus, modeCode)
}
if (newStatus == "Cleaning" && state.activeCleanRun?.getAt(mac) != null) {
state.activeCleanRun[mac].everConfirmedCleaning = true
}
// Resumes a sweep continuation that continueSweepIfNeeded() deferred
// because the vacuum was still returning to its dock -- now that it's
// actually settled (Docked/Standby), it's safe to dispatch the next room.
if (state.rotationSweepPending?.getAt(mac) && newStatus in ["Docked", "Standby"]) {
state.rotationSweepPending[mac] = false
ifDebug("handleVacuumStatusResponse(${mac}): now settled (status=${newStatus}) -- resuming deferred sweep continuation")
runIn(5, "continueSweepDispatch", [data: [mac: mac], overwrite: false])
}
checkPossiblyStuck(mac, newStatus, isCharging, d)
state.lastKnownStatus[mac] = newStatus
rescheduleDynamicPoll()
checkStaleActiveCleanRun(mac)
// pollVacuum() fires the props and status polls as two independent async
// HTTP calls every cycle, and both callbacks used to call this same pair
// of updates -- confirmed live: every poll produced two near-identical
// nextRoomDueAt/lastRefresh events milliseconds apart, since the second
// callback's d.currentValue() read doesn't reliably see the first
// callback's sendEvent yet (two concurrent async contexts), so
// sendEventIfChanged's dedup (1.22.0) couldn't catch it. Rotation-preview
// state doesn't depend on anything specific to the props poll, so this
// only runs from here now -- once per cycle, not twice.
updateRotationPreviewAttributes(d, mac)
d.sendEvent(name: "lastRefresh", value: new Date().format("MM/dd/yyyy HH:mm:ss", location.timeZone))
}
// Safety net for a dispatch that never actually took effect -- confirmed
// live: a room-clean command sent while the vacuum was still returning to
// its dock got silently dropped (never entered Cleaning at all, just
// finished docking on its own). Without this, that room would stay
// excluded from rotation forever (see previewNextRooms's in-progress
// exclusion), since the normal Cleaning -> non-Cleaning transition that
// clears an active run never happens for a dispatch that never started.
// Only applies to a run that never confirmed Cleaning even once -- a
// legitimately long-running clean is left alone and resolves normally.
private void checkStaleActiveCleanRun(String mac) {
checkOrphanedSweep(mac)
def run = state.activeCleanRun?.getAt(mac)
if (!run || run.learning) return
// A run parked mid-resume (see finishActiveCleanRun's mode-11 handling)
// has already confirmed Cleaning once, so the "never started" check
// below doesn't apply to it -- but it still needs its own timeout in
// case the expected resume never actually happens (e.g. a charging
// fault), so a room can't stay excluded from rotation forever waiting
// for a resume that's never coming.
if (run.pausedAt) {
double hoursSincePause = (now() - (run.pausedAt as Long)) / 3600000.0
if (hoursSincePause >= 3.0) {
log.warn "Wyze Vacuum ${mac}: room-clean run has been waiting to resume for ${String.format('%.1f', hoursSincePause)}h with no sign of it -- finishing it as-is with what was measured so far."
sendVacuumNotification("${getChildDevice(mac)?.displayName ?: mac}: expected to resume cleaning after charging, but hasn't after ${String.format('%.1f', hoursSincePause)} hours -- worth checking on it.")
finishActiveCleanRun(mac, 0, "Docked", null)
endRotationSweep(mac) // nothing left driving it -- see the give-up below
}
return
}
if (run.everConfirmedCleaning) return
def startedAt = run.startedAt as Long
if (!startedAt) return
double minutesSinceDispatch = (now() - startedAt) / 60000.0
// Confirmed live: Wyze's control API can acknowledge a room-clean
// dispatch (code:1, no error) that the vacuum then simply never acts
// on -- even while sitting fully charged and idle (not mid-transit
// returning to the dock, which is the other known drop case). Since
// most triggers here are presence-based and only fire once (e.g. "away"),
// a silently-ignored command can otherwise cost an entire day before
// the next chance. One automatic retry partway through the stale
// window meaningfully improves the odds of it actually starting.
if (!run.retried && minutesSinceDispatch >= 2.0) {
log.warn "Wyze Vacuum ${mac}: room-clean dispatched ${Math.round(minutesSinceDispatch)} min ago never actually started cleaning -- retrying the command once"
run.retried = true
venusControl(mac, 0, 1, run.roomIds)
return
}
if (minutesSinceDispatch >= 10.0) {
log.warn "Wyze Vacuum ${mac}: room-clean dispatched ${Math.round(minutesSinceDispatch)} min ago never actually started cleaning, even after a retry -- clearing it so its room(s) aren't excluded from rotation indefinitely."
sendVacuumNotification("${getChildDevice(mac)?.displayName ?: mac}: a room-clean command was sent but the vacuum never started -- worth checking it's not stuck or offline.")
state.activeCleanRun.remove(mac)
// The sweep has to end here too. It only ever advances off the back of
// a run actually finishing, so once the run is abandoned nothing will
// ever clear the flag -- and since 1.29.0 reads it as "work
// outstanding" for the switch attribute, leaving it set latched the
// switch on indefinitely. Confirmed live: a dispatch at 8% battery was
// never acted on, was given up on here, and the switch stayed on for
// hours afterwards with the vacuum sitting idle on its dock.
endRotationSweep(mac)
}
}
// Every place a sweep stops needs to clear all three pieces of its state --
// missing one is what caused the latched-switch bug above.
private void endRotationSweep(String mac) {
state.rotationSweepActive?.put(mac, false)
state.rotationSweepPending?.put(mac, false)
state.rotationSweepStartedAt?.remove(mac)
}
// Safety net for any path that abandons work without going through
// endRotationSweep. A sweep flag set while nothing is cleaning, no dispatch is
// outstanding and nothing is queued to dispatch means the sweep can never
// advance again -- so it isn't "work outstanding," it's leftover state. The
// grace period covers the few seconds between a room finishing and
// continueSweepDispatch firing, so a healthy sweep is never cut short.
private void checkOrphanedSweep(String mac) {
state.sweepIdleSince = state.sweepIdleSince ?: [:]
boolean sweeping = state.rotationSweepActive?.getAt(mac)
boolean busy = state.activeCleanRun?.containsKey(mac) ||
state.rotationSweepPending?.getAt(mac) ||
state.lastKnownStatus?.getAt(mac) == "Cleaning"
if (!sweeping || busy) {
state.sweepIdleSince.remove(mac)
return
}
def idleSince = state.sweepIdleSince[mac]
if (!idleSince) {
state.sweepIdleSince[mac] = now()
return
}
if ((now() - (idleSince as Long)) / 60000.0 >= 2.0) {
ifDebug("checkOrphanedSweep(${mac}): sweep flag set with nothing running or queued -- clearing it")
endRotationSweep(mac)
state.sweepIdleSince.remove(mac)
}
}
// Confirmed live: a room-clean run stopped after 3 minutes, the vacuum
// reported "Standby" (idle, NOT charging) instead of Cleaning/Docked/
// Returning to charge, and just sat there for 7+ hours with the battery
// draining continuously -- consistent with it being physically stuck (e.g.
// wedged under furniture) rather than resting. A healthy vacuum is always
// either cleaning, on its way back to the dock, or sitting on the dock
// charging -- "idle and not charging" for an extended stretch is itself
// the anomaly, regardless of the underlying cause. One notification per
// episode, not a repeat on every poll.
private void checkPossiblyStuck(String mac, String newStatus, boolean isCharging, def d) {
state.standbyStartedAt = state.standbyStartedAt ?: [:]
state.stuckNotified = state.stuckNotified ?: [:]
if (newStatus == "Standby" && !isCharging) {
if (!state.standbyStartedAt[mac]) state.standbyStartedAt[mac] = now()
double minutesStandby = (now() - (state.standbyStartedAt[mac] as Long)) / 60000.0
if (minutesStandby >= 30.0 && !state.stuckNotified[mac]) {
state.stuckNotified[mac] = true
log.warn "Wyze Vacuum ${mac}: possibly stuck -- idle and not charging for ${Math.round(minutesStandby)} min"
sendVacuumNotification("${d?.displayName ?: mac} has been idle and not charging for over 30 minutes -- it may be stuck (e.g. wedged under furniture), powered off, or otherwise needs attention.")
}
} else {
state.standbyStartedAt.remove(mac)
state.stuckNotified[mac] = false
}
}
private void venusGetAsync(String path, Map query, String callbackHandler, Map data) {
if (!state.wyzeAccessToken) {
log.warn "Wyze Vacuum: skipping ${path} for ${data?.mac} -- not logged in. Click Log In in the app."
return
}
def nonce = now()
def requestId = md5Hex(md5Hex(nonce.toString()))
def signingKey = md5Hex("${state.wyzeAccessToken}${VENUS_SALT}")
def qp = new TreeMap()
(query ?: [:]).each { k, v -> qp[k] = v?.toString() }
qp["nonce"] = nonce.toString()
def sigString = qp.collect { k, v -> "${k}=${v}" }.join("&")
def headers = [
"access_token" : state.wyzeAccessToken,
"requestid" : requestId,
"appid" : VENUS_APP_ID,
"appinfo" : "wyze_android_${APP_VERSION}",
"phoneid" : state.wyzePhoneId,
"User-Agent" : "wyze_android_${APP_VERSION}",
"Accept-Encoding": "gzip",
"signature2" : hmacMd5Hex(signingKey, sigString)
]
def asyncData = new LinkedHashMap(data ?: [:])
asyncData["_venusPath"] = path
asyncData["_venusQuery"] = query
asyncData["_retried"] = false
try {
asynchttpGet(callbackHandler, [uri: VENUS_BASE, path: path, query: qp, headers: headers, timeout: 20], asyncData)
} catch (e) {
log.error "Wyze Venus async GET ${path} failed to dispatch: ${e}"
}
}
// Returns true if the caller should stop (either a retry was dispatched, or a
// non-recoverable error was logged); false means the response is good to parse.
private boolean handleVenusAsyncError(resp, Map data, String callbackHandler) {
Integer status = null
try { status = resp?.status as Integer } catch (e) { status = null }
boolean hasError = false
try { hasError = resp?.hasError() as boolean } catch (e) { hasError = (status != null && status >= 400) }
def mac = data?.mac
if (!hasError) {
// Wyze signals some failures (e.g. an expired access token) with an
// HTTP 200 and an error code/message in the JSON body instead of a
// real 401/403 status -- confirmed live: {code:2001, message:"Access
// token error"}. That response has no "data" at all, so it was
// silently surfacing as "no props returned" with no retry ever
// firing. Treat it the same as a real 401/403.
def body = parseAsyncJson(resp)
if (isAuthErrorResponse(body)) {
if (!data?._retried) {
ifDebug("Wyze Venus ${callbackHandler} got an in-body auth error (${body?.code}: ${body?.message}) for ${mac}, refreshing token (async) and retrying once")
def retryContext = new LinkedHashMap(data)
retryContext["_retryCallback"] = callbackHandler
refreshTokenAsync(retryContext)
} else {
log.error "Wyze Venus ${callbackHandler} still getting an auth error for ${mac} after a retry: ${body}"
}
return true
}
return false
}
if (status in [401, 403] && !data?._retried) {
ifDebug("Wyze Venus ${callbackHandler} got ${status} for ${mac}, refreshing token (async) and retrying once")
def retryContext = new LinkedHashMap(data)
retryContext["_retryCallback"] = callbackHandler
refreshTokenAsync(retryContext)
return true
}
def errMsg = null
try { errMsg = resp?.getErrorMessage() } catch (e) { errMsg = resp?.error }
log.error "Wyze Venus ${callbackHandler} failed (${status}) for ${mac}: ${errMsg}"
return true
}
// Wyze doesn't always use HTTP status codes for auth failures -- some come
// back as HTTP 200 with an error code/message in the body instead. Matches
// both, since we don't have a confirmed full enumeration of error shapes.
private boolean isAuthErrorResponse(Map result) {
if (result == null) return false
if (result.code?.toString() == "2001") return true
def text = "${result.message ?: ''} ${result.msg ?: ''}".toLowerCase()
return text.contains("access token")
}
private Map parseAsyncJson(resp) {
try {
if (resp?.json) return resp.json
def text = resp?.data
return text ? new JsonSlurper().parseText(text) : null
} catch (e) {
log.error "Wyze Vacuum: failed to parse async response: ${e}"
return null
}
}
// Async token refresh so a 401/403 encountered inside an async poll callback
// never has to make a blocking call to recover -- a synchronous call from
// inside an async handler tripped Hubitat's load guardrail just as much as
// the original synchronous poll did, just relocated to a different line.
private void refreshTokenAsync(Map retryContext) {
if (!state.wyzeRefreshToken) {
log.error "Wyze Vacuum: no refresh token available -- click Re-login in the app."
return
}
def payload = new LinkedHashMap()
payload["refresh_token"] = state.wyzeRefreshToken
payload["sv"] = "d91914dd28b7492ab9dd17f7707d35a3"
payload["access_token"] = state.wyzeAccessToken
payload["app_name"] = "com.hualai"
payload["app_ver"] = "com.hualai___${APP_VERSION}"
payload["app_version"] = APP_VERSION
payload["phone_id"] = state.wyzePhoneId
payload["phone_system_type"] = "2"
payload["sc"] = WYZE_SC
payload["ts"] = now()
try {
asynchttpPost("handleTokenRefreshResponse", [
uri: API_BASE, path: "/app/user/refresh_token", requestContentType: "application/json",
headers: ["Connection": "keep-alive"], body: JsonOutput.toJson(payload), timeout: 20
], new LinkedHashMap(retryContext ?: [:]))
} catch (e) {
log.error "Wyze Vacuum: async token refresh dispatch failed: ${e}"
}
}
def handleTokenRefreshResponse(resp, data) {
boolean hasError = false
try { hasError = resp?.hasError() as boolean } catch (e) { hasError = false }
Map result = hasError ? null : parseAsyncJson(resp)
def tokenData = result?.data ?: result
if (!tokenData?.access_token) {
def errMsg = null
try { errMsg = resp?.getErrorMessage() } catch (e) { errMsg = resp?.error }
log.error "Wyze Vacuum: async token refresh failed for ${data?.mac}: ${result ?: errMsg}. If this keeps happening, click Re-login in the app."
return
}
state.wyzeAccessToken = tokenData.access_token
if (tokenData.refresh_token) state.wyzeRefreshToken = tokenData.refresh_token
ifDebug("Wyze token refresh succeeded (async)")
def path = data?._venusPath
def callbackHandler = data?._retryCallback
if (path && callbackHandler) {
def retryData = new LinkedHashMap(data)
retryData["_retried"] = true
venusGetAsync(path, data?._venusQuery, callbackHandler, retryData)
}
}
// Wyze's own firmware already has some low-battery return-to-charge-then-
// resume behavior built in (observed live: mode 11 = "docked, cleaning will
// resume after charging", battery climbing while docked). This is a
// supplementary, user-controlled trigger point -- lets you dock earlier
// more conservatively than whatever threshold the vacuum uses internally.
// Calling dock() here is safe even if the vacuum would have self-docked
// shortly after anyway.
private void checkLowBatteryAutoDock(String mac, Integer batteryPct) {
if (batteryPct == null) return
def threshold = (settings["lowBatteryDockPercent_${mac}"] ?: 0) as Integer
if (threshold <= 0) return // disabled
def isCleaning = state.lastKnownStatus?.getAt(mac) == "Cleaning"
state.lowBatteryDockTriggered = state.lowBatteryDockTriggered ?: [:]
if (isCleaning && batteryPct < threshold) {
if (!state.lowBatteryDockTriggered[mac]) {
log.warn "Wyze Vacuum ${mac}: battery ${batteryPct}% below ${threshold}% threshold while cleaning — sending back to dock"
state.lowBatteryDockTriggered[mac] = true
dockVacuum(mac)
}
} else {
// Reset once no longer cleaning or battery has recovered, so the
// next time it drops below threshold this can trigger again.
state.lowBatteryDockTriggered[mac] = false
}
}
private void updateFaultAttribute(def d, String mac, Map props) {
def faultCode = toInt(props.fault_code)
def hasRawFault = faultCode && faultCode != 0
def ignored = ignoredFaultCodesList()
def isFault = hasRawFault && !(faultCode in ignored)
d.sendEvent(name: "fault", value: isFault ? "${props.fault_type ?: faultCode}" : "none")
// Log full context for *any* nonzero fault_code, even an ignored one --
// this is the evidence trail for confirming/refuting which codes are
// real problems vs. benign status codes (e.g. charging/fully charged)
// that apparently share this same field.
if (hasRawFault) {
log.info "Wyze Vacuum ${mac} fault_code=${faultCode}${faultCode in ignored ? ' (ignored)' : ''} fault_type=${props.fault_type} mode=${props.mode} chargeState=${props.chargeState} status=${d.currentValue('status')} battery=${d.currentValue('battery')}"
}
state.lastNotifiedFault = state.lastNotifiedFault ?: [:]
if (isFault) {
if (settings.notifyStuck && state.lastNotifiedFault[mac] != faultCode) {
sendVacuumNotification("${d.displayName} reported a fault: ${props.fault_type ?: faultCode}")
state.lastNotifiedFault[mac] = faultCode
}
} else {
state.lastNotifiedFault[mac] = null // clear so a future recurrence of the same fault code re-notifies
}
}
private List ignoredFaultCodesList() {
def raw = settings.ignoredFaultCodes ?: "2102,2103,2105"
return raw.split(",").collect { toInt(it.trim()) }.findAll { it != null }
}
private List roomNamesFor(String mac, List ids) {
def known = state.discoveredRooms?.getAt(mac) ?: []
return (ids ?: []).collect { id -> known.find { it.id == id }?.name ?: "Room ${id}" }
}
// Builds the "started cleaning" notification text. Cleaning is detected
// purely from polled status transitions, so this fires for *any* run --
// including ones this app never dispatched (started from the Wyze app, the
// vacuum's own schedule, or its physical button).
//
// Earlier versions said "whole house" whenever there was no app-dispatched
// room list, which conflated two very different situations. Confirmed live:
// a 2-zone clean started from the Wyze app was announced as "whole house",
// which was simply untrue -- Wyze's API doesn't report which rooms/zones an
// externally-started run picked, so the honest answer is that we don't know.
// "whole house" is now claimed only when this app's own start() command is
// what actually kicked the run off.
private String cleaningStartedMessage(String mac, def d, boolean isResume, boolean cancellingResume = false) {
def name = d?.displayName ?: mac
if (cancellingResume) return "${name} restarted an unfinished clean on its own after charging — sending it back to the dock. It'll come up again next rotation."
if (isResume) return "${name} resumed cleaning after charging (same job, not a new one)."
def activeRoomIds = state.activeCleanRun?.getAt(mac)?.roomIds
if (activeRoomIds) return "${name} started cleaning: ${roomNamesFor(mac, activeRoomIds).join(', ')}."
if (consumeAppWholeHouseStart(mac)) return "${name} started cleaning: whole house."
return "${name} started cleaning (started outside Hubitat — rooms unknown)."
}
// startVacuum() leaves a timestamp behind so the *next* poll that observes
// Cleaning can tell an app-issued whole-house start from an externally
// started run. Consumed on first use; ignored if stale, since a whole-house
// start that took effect is always confirmed within a poll or two (the same
// window checkStaleActiveCleanRun uses to give up on a room dispatch).
private boolean consumeAppWholeHouseStart(String mac) {
def startedAt = state.appWholeHouseStartAt?.getAt(mac)
if (!startedAt) return false
state.appWholeHouseStartAt.remove(mac)
return (now() - (startedAt as Long)) <= 10 * 60 * 1000L
}
// Set when a cleaning session exits via mode 11 ("docked, cleaning will
// resume"), so the resume that follows is recognized as a continuation
// rather than announced as a brand-new run. Tracked independently of
// state.activeCleanRun, since an externally started run has no active-run
// record at all but resumes exactly the same way -- confirmed live: a run
// paused at 6% battery, charged for ~2 hours, then resumed on its own.
// Expires after 3 hours, matching checkStaleActiveCleanRun's give-up window,
// so a genuinely new run much later isn't mislabeled as a resume.
private boolean consumePausedForResume(String mac) {
def pausedAt = state.pausedForResumeAt?.getAt(mac)
if (!pausedAt) return false
state.pausedForResumeAt.remove(mac)
return (now() - (pausedAt as Long)) <= 3 * 60 * 60 * 1000L
}
// Records that dock()/pause()/start() was commanded from this side, so the
// Cleaning -> non-Cleaning transition that follows can be recognized as an
// interruption rather than a finish. Set on every such command (cheap, and
// cleared again the moment a new run starts) -- the vacuum's own status
// gives no way to tell "returned to charge because it finished" apart from
// "returned to charge because something told it to," and getting that wrong
// silently drops a room out of rotation for a full cycle.
// "Is there cleaning still outstanding," independent of whether the vacuum
// happens to be moving this second. Drives the switch attribute (see
// handleVacuumStatusResponse) so a job paused for charging still reads as on.
// Every condition here is self-limiting -- a pause expires after 3 hours, a
// dispatch that never starts is cleared within 10 minutes, and a sweep clears
// itself once nothing is due -- so the switch can't latch on indefinitely.
private boolean hasWorkPending(String mac) {
def pausedAt = state.pausedForResumeAt?.getAt(mac)
if (pausedAt && (now() - (pausedAt as Long)) <= 3 * 60 * 60 * 1000L) return true
if (state.activeCleanRun?.containsKey(mac)) return true
if (state.rotationSweepActive?.getAt(mac) || state.rotationSweepPending?.getAt(mac)) return true
return false
}
// Stopping on purpose (dock/pause, usually the switch being turned off when
// someone gets home) has to end the *job*, not just whatever the vacuum is
// doing at that instant. During a battery pause the vacuum is already docked,
// so there's no Cleaning -> non-Cleaning transition coming to close the run
// out -- without this it would stay open until its 3-hour timeout, keeping the
// switch on, and the firmware would resume the job later regardless.
private void cancelPendingWork(String mac) {
boolean hadPausedJob = state.pausedForResumeAt?.getAt(mac) != null
boolean hadRun = state.activeCleanRun?.containsKey(mac)
if (!hadPausedJob && !hadRun) return
// Rooms are deliberately left uncredited -- the job was cancelled, not
// finished, so they stay due and come up again next rotation.
if (state.lastKnownStatus?.getAt(mac) != "Cleaning") state.activeCleanRun?.remove(mac)
if (hadPausedJob) {
state.pausedForResumeAt.remove(mac)
// There's no API to tell the vacuum to forget a pending resume, so
// remember the cancellation instead: if it starts up again on its own,
// it gets sent straight back (see the resume handling on the Cleaning
// transition). Same 3-hour horizon as the pause itself.
state.resumeCancelled = state.resumeCancelled ?: [:]
state.resumeCancelled[mac] = now()
ifDebug("cancelPendingWork(${mac}): cancelled a job that was paused for charging")
}
}
private boolean consumeResumeCancelled(String mac) {
def at = state.resumeCancelled?.getAt(mac)
if (!at) return false
state.resumeCancelled.remove(mac)
return (now() - (at as Long)) <= 3 * 60 * 60 * 1000L
}
private void markCommandInterrupt(String mac, String how, boolean silent = false) {
state.commandInterrupt = state.commandInterrupt ?: [:]
state.commandInterrupt[mac] = [at: now(), how: how, silent: silent]
}
// Sends a self-restarted job back to the dock, for vacuums with
// "Don't let it auto-resume" turned on. Runs a couple of seconds after the
// poll that spotted the restart, to keep the synchronous control call out of
// the async callback. Re-checks state first: if it stopped on its own in the
// meantime there's nothing to cancel.
def cancelAutoResumeDock(data) {
def mac = data?.mac
if (!mac) return
if (state.lastKnownStatus?.getAt(mac) != "Cleaning") {
ifDebug("cancelAutoResumeDock(${mac}): no longer cleaning, nothing to cancel")
return
}
log.info "Wyze Vacuum ${mac}: vacuum restarted an unfinished job on its own -- docking it (auto-resume is turned off for this vacuum)"
dockVacuum(mac)
// Marked silent so the run's end doesn't also announce "was docked N min
// into cleaning" -- the start-side notification already explained this.
// Still counts as an interruption, so the room keeps its pending status
// and its learned clean time (see finishActiveCleanRun).
markCommandInterrupt(mac, "docked", true)
}
// Consumed at the end of a cleaning session. The window only needs to cover
// command -> next poll observing the vacuum actually reacting (seconds to a
// couple of minutes, since these commands poll immediately), but is generous
// since a stale marker is cleared outright whenever a new run starts.
private Map consumeCommandInterrupt(String mac) {
def rec = state.commandInterrupt?.getAt(mac)
if (!rec) return null
state.commandInterrupt.remove(mac)
return (now() - (rec.at as Long)) <= 10 * 60 * 1000L ? rec : null
}
// Fires once per Cleaning -> non-Cleaning transition, regardless of whether the
// clean finished naturally, was paused, or was interrupted by a dock/stop.
//
// NOTE: an earlier version of this tried to detect "returned to charge
// because the battery got critically low" (via a battery-percent threshold)
// and treat that as not-a-real-finish. Live data disproved that outright:
// a room legitimately finished (confirmed against the map) with the battery
// down at 21% -- low battery at dock time is apparently unremarkable, not a
// sign of an interrupted room. Reverted back to trusting the vacuum: any
// exit that isn't Paused/Error is a genuine finish, full stop.
private void handleCleaningSessionEnd(String mac, def reportedCleanTimeMinutes, def d, String newStatus, def modeCode = null) {
def sessionStart = state.cleaningSessionStart?.getAt(mac)
def reported = toInt(reportedCleanTimeMinutes)
Integer elapsedMin = (reported != null && reported > 0)
? reported
: (sessionStart ? (Math.max(0, Math.round((now() - sessionStart) / 60000.0)) as Integer) : 0)
def run = state.activeCleanRun?.getAt(mac)
Map finishResult = null
boolean willResume = modeSignalsResume(modeCode)
// Remembered independently of the active-run record below, so the
// eventual resume is recognized as a continuation even for a run this
// app never dispatched (see consumePausedForResume).
if (willResume) {
state.pausedForResumeAt = state.pausedForResumeAt ?: [:]
state.pausedForResumeAt[mac] = now()
}
// Left alone on a mode-11 exit so it survives to the run's real end --
// a battery pause isn't the transition this describes.
Map interrupt = willResume ? null : consumeCommandInterrupt(mac)
if (run?.learning) {
handleLearningRoomEnd(mac, run, elapsedMin, newStatus)
} else if (run) {
finishResult = finishActiveCleanRun(mac, elapsedMin, newStatus, modeCode, interrupt != null)
// Not a real finish -- don't push the sweep into a new room while
// this one is still expected to resume on its own.
if (!willResume) continueSweepIfNeeded(mac, newStatus)
}
accumulateBinHours(mac, elapsedMin)
if (!willResume) learnBatteryDrain(mac, elapsedMin, d)
// Same reasoning -- don't tell the user it "finished" when it's really
// just pausing to resume the same job; the eventual genuine finish
// fires its own correct notification once totalElapsed is known.
// interrupt.silent covers a stop this app issued *and* already announced
// at the time (cancelled auto-resume) -- the run still counts as
// interrupted for crediting, it just doesn't get a second notification.
if (!willResume && settings.notifyCleaningFinished && elapsedMin > 0 && !interrupt?.silent) {
sendVacuumNotification(cleaningEndedMessage(mac, d, elapsedMin, finishResult, interrupt))
}
state.cleaningSessionStart?.remove(mac)
if (!willResume) state.cleaningStartBattery?.remove(mac)
}
// One message for every way a cleaning session can end, so an interrupted
// run doesn't get announced as a finish. Confirmed live as a real gap: an
// automation docking the vacuum partway through a room (e.g. "someone came
// home") produced "finished cleaning after N min -- cleaned: ", which
// was wrong twice over -- it didn't finish, and the room shouldn't have been
// credited (see finishActiveCleanRun's `interrupted` handling).
private String cleaningEndedMessage(String mac, def d, Integer elapsedMin, Map finishResult, Map interrupt) {
def name = d?.displayName ?: mac
def parts = []
if (finishResult?.completedNames) parts << "cleaned: ${finishResult.completedNames.join(', ')}"
if (finishResult?.incompleteNames) parts << "not completed (will retry): ${finishResult.incompleteNames.join(', ')}"
def detail = parts ? " -- ${parts.join('; ')}" : ""
if (interrupt) return "${name} was ${interrupt.how} ${elapsedMin} min into cleaning${detail}."
return "${name} finished cleaning after ${elapsedMin} min${detail}."
}
// Continues a rotation sweep (see cleanNextRooms) once a room-clean run
// genuinely finishes -- if a sweep is active for this vacuum and there's
// still at least one rotation room actually due, dispatches the next batch;
// otherwise the sweep is done and clears itself.
private void continueSweepIfNeeded(String mac, String newStatus) {
if (!(state.rotationSweepActive?.getAt(mac))) return
if (newStatus == "Paused" || newStatus == "Error") {
// Landed in an ambiguous/problem state -- don't guess at whether to
// push forward into a new room. Treat it the same as an explicit stop.
ifDebug("continueSweepIfNeeded(${mac}): ended as ${newStatus}, not continuing the sweep")
endRotationSweep(mac)
return
}
// Normally the sweep only continues while something's actually due
// (elapsed >= that room's own cycle length -- see pendingRoomCount).
// In continuous mode, it ignores that and just keeps working through
// the rotation list on a loop -- previewNextRooms() always returns
// *something* (whichever room is least-recently-cleaned), it just
// isn't gated on "due" in this mode.
boolean continuous = (settings["rotationContinuousMode_${mac}"] ?: false) as boolean
// Continuous mode has no natural "nothing left to do" stopping point
// (it loops the rotation list forever), so it's the one mode that can
// optionally be capped to a max runtime instead, counted from when the
// whole sweep started (see cleanNextRooms). Checked before the
// somethingToDispatch gate below since continuous mode would otherwise
// always have something to dispatch.
if (continuous && (settings["rotationContinuousLimitEnabled_${mac}"] ?: false)) {
def maxMinutes = (settings["rotationContinuousMaxMinutes_${mac}"] ?: 0) as Integer
def startedAt = state.rotationSweepStartedAt?.getAt(mac) as Long
if (maxMinutes > 0 && startedAt) {
long elapsedMin = (now() - startedAt) / 60000
if (elapsedMin >= maxMinutes) {
ifDebug("continueSweepIfNeeded(${mac}): continuous sweep hit its ${maxMinutes}-minute limit (${elapsedMin} min elapsed), docking")
endRotationSweep(mac)
dockVacuum(mac)
return
}
}
}
boolean somethingToDispatch = continuous ? !previewNextRooms(mac).isEmpty() : pendingRoomCount(mac) > 0
if (!somethingToDispatch) {
ifDebug("continueSweepIfNeeded(${mac}): ${continuous ? 'nothing left to clean' : 'nothing else due'}, sweep finished")
endRotationSweep(mac)
return
}
if (newStatus == "Returning to charge") {
// Confirmed live: dispatching a new room-clean command while the
// vacuum is still physically driving back to the dock gets silently
// ignored -- it just finishes docking on its own instead of
// redirecting, and the dispatched room never actually starts. Defer
// until it's actually settled (Docked/Standby); see the matching
// check in handleVacuumStatusResponse that resumes this once that
// happens. Fast polling stays engaged via rotationSweepPending so
// this gets picked up within about a minute, not up to 15.
ifDebug("continueSweepIfNeeded(${mac}): still returning to dock, deferring next dispatch until it settles")
state.rotationSweepPending = state.rotationSweepPending ?: [:]
state.rotationSweepPending[mac] = true
return
}
state.rotationSweepPending?.put(mac, false)
ifDebug("continueSweepIfNeeded(${mac}): more due rooms remain, continuing sweep")
runIn(5, "continueSweepDispatch", [data: [mac: mac], overwrite: false])
}
// Re-checks the sweep flag before dispatching -- if dock()/pause()/off() was
// called in the meantime (which clears rotationSweepActive), this quietly
// no-ops instead of reactivating a sweep the user just stopped.
// NOTE for every runIn() that schedules this: they must pass
// `overwrite: false`. Hubitat keys pending one-shot jobs by handler *method
// name*, and overwrites by default -- so with more than one vacuum, the
// second one to schedule a continuation would silently cancel the first's,
// stalling that vacuum's sweep. pollAllVacuums() polls every vacuum in a
// single execution, so their status callbacks land milliseconds apart and two
// sweeps advancing in the same poll cycle would collide every time, not
// occasionally. The mac is carried in the job's own data, so the copies don't
// interfere; this function re-checks the sweep flag anyway.
def continueSweepDispatch(data) {
def mac = data?.mac
if (!mac || !(state.rotationSweepActive?.getAt(mac))) return
cleanNextRooms(mac)
}
private void accumulateBinHours(String mac, Integer elapsedMin) {
if (!elapsedMin || elapsedMin <= 0) return
state.cleaningHoursSinceEmpty = state.cleaningHoursSinceEmpty ?: [:]
double hrs = ((state.cleaningHoursSinceEmpty[mac] ?: 0.0) as Double) + (elapsedMin / 60.0)
def threshold = (settings["emptyBinHours_${mac}"] ?: 0) as Double
def d = getChildDevice(mac)
if (threshold > 0 && hrs >= threshold) {
sendVacuumNotification("${d?.displayName ?: mac} has cleaned for ${String.format('%.1f', hrs)} hours since the bin was last emptied — time to empty it.")
hrs = 0.0
}
state.cleaningHoursSinceEmpty[mac] = hrs
d?.sendEvent(name: "hoursSinceEmptied", value: Math.round(hrs * 10) / 10.0)
}
def resetBinTimer(String mac) {
state.cleaningHoursSinceEmpty = state.cleaningHoursSinceEmpty ?: [:]
state.cleaningHoursSinceEmpty[mac] = 0.0
getChildDevice(mac)?.sendEvent(name: "hoursSinceEmptied", value: 0)
ifDebug("resetBinTimer(${mac})")
}
private void sendVacuumNotification(String msg) {
ifDebug("Notification: ${msg}")
settings.notifyDevices?.each { it.deviceNotification(msg) }
}
private String suctionLevelName(def code) {
def map = [1: "Quiet", 2: "Standard", 3: "Strong"]
return map[toInt(code)] ?: "Unknown (${code})"
}
private String vacuumModeDescription(def code) {
def c = toInt(code)
if (c in [1, 30, 1101, 1201, 1301, 1401]) return "Cleaning"
if (c in [4, 31, 1102, 1202, 1302, 1402]) return "Paused"
if (c in [10, 32, 1103, 1203, 1303, 1403]) return "Cleaning completed, returning to charge"
if (c == 5) return "Returning to charge"
if (c in [11, 33, 1104, 1204, 1304, 1404]) return "Docked, cleaning will resume"
if (c in [0, 14, 29, 35, 40]) return "Idle"
return "Mode ${c}"
}
// The main "status" attribute (and everything gated on Cleaning/not-Cleaning
// -- notifications, room crediting, sweep continuation, poll interval) is
// derived from mode, using the same code groups as vacuumModeDescription
// above, collapsed down since that logic only needs to distinguish a
// handful of coarse states rather than every nuance. mode's own "Idle"
// bucket doesn't distinguish parked-on-dock from genuinely off-dock, so
// charging (a direct boolean, not a translated code) breaks that tie.
private String deriveStatusFromMode(Integer modeCode, boolean charging) {
if (modeCode in [1, 30, 1101, 1201, 1301, 1401]) return "Cleaning"
if (modeCode in [4, 31, 1102, 1202, 1302, 1402]) return "Paused"
if (modeCode in [10, 32, 1103, 1203, 1303, 1403, 11, 33, 1104, 1204, 1304, 1404]) return "Returning to charge"
if (modeCode == 5) return "Returning to charge"
return charging ? "Docked" : "Standby"
}
// mode 11 (and its higher-tier equivalents) specifically means "docked,
// cleaning will resume" -- distinct from mode 10 ("cleaning completed,
// returning to charge"), even though both collapse to the same "Returning
// to charge" status label above. Confirmed live: a room exiting through
// mode 11 really did auto-resume ~1h42m later and continued the same job
// for another 25 minutes. Used specifically to gate room-completion
// crediting -- treating any non-Paused/Error exit as a genuine finish
// (the general rule, kept after live testing disproved a battery-percent
// version of this same idea) is wrong for this one specific, unambiguous
// signal.
private boolean modeSignalsResume(def modeCode) {
def c = toInt(modeCode)
return c != null && (c in [11, 33, 1104, 1204, 1304, 1404])
}
// =================== Commands from Driver ===================
def startVacuum(String mac) {
ifDebug("startVacuum: ${mac}")
endRotationSweep(mac) // whole-house start is a different mode than room rotation
state.pausedForResumeAt?.remove(mac) // an explicit start is a new run, not a resume of the old one
state.resumeCancelled?.remove(mac) // and a deliberate start overrides an earlier cancellation
// Lets the poll that later observes Cleaning report this honestly as a
// whole-house run we started, vs. one started outside Hubitat.
state.appWholeHouseStartAt = state.appWholeHouseStartAt ?: [:]
state.appWholeHouseStartAt[mac] = now()
markCommandInterrupt(mac, "switched to a whole-house clean")
venusControl(mac, 0, 1) // GLOBAL_SWEEPING / START
pollVacuum(mac)
}
def pauseVacuum(String mac) {
ifDebug("pauseVacuum: ${mac}")
endRotationSweep(mac) // explicit stop -- don't auto-continue to the next room
state.appWholeHouseStartAt?.remove(mac)
markCommandInterrupt(mac, "paused")
cancelPendingWork(mac)
venusControl(mac, 0, 2) // GLOBAL_SWEEPING / PAUSE
pollVacuum(mac)
}
def dockVacuum(String mac) {
ifDebug("dockVacuum: ${mac}")
endRotationSweep(mac) // explicit stop -- don't auto-continue to the next room
state.appWholeHouseStartAt?.remove(mac)
markCommandInterrupt(mac, "docked")
// Ends the job outright, including one that's only paused for charging --
// see cancelPendingWork. Docking an already-docked vacuum is a no-op at
// the device, so the cancellation has to be tracked on this side.
cancelPendingWork(mac)
venusControl(mac, 3, 1) // RETURN_TO_CHARGING / START
pollVacuum(mac)
}
def setVacuumSuctionLevel(String mac, String level) {
ifDebug("setVacuumSuctionLevel: ${mac} -> ${level}")
def code = [Quiet: 1, Standard: 2, Strong: 3][level] ?: 2
def body = [did: mac, model: VACUUM_PRODUCT_MODEL, cmd: "set_preference", params: [[ctrltype: 1, value: code]], is_sub_device: 0]
venusRequest("POST", "/plugin/venus/set_iot_action", [:], body)
pollVacuum(mac)
}
def refreshVacuum(String mac) {
pollVacuum(mac)
}
private void venusControl(String mac, int type, int value, List rooms = null) {
def body = [type: type, value: value, vacuumMopMode: 0]
if (rooms) body["rooms_id"] = rooms
def resp = venusRequest("POST", "/plugin/venus/${mac}/control", [:], body)
// Wyze returns code as an integer (1), not the string "1" -- comparing
// against a string here made every successful call log a false warning.
if (resp != null && resp.code?.toString() != "1") log.warn "Wyze Vacuum control (${mac}) returned: ${resp}"
}
// =================== Room rotation ===================
def cleanRoomSlot(String mac, int slot) {
def roomIdStr = settings["roomSlot${slot}_${mac}"]
if (!roomIdStr) { log.warn "Wyze Vacuum: slot ${slot} has no room assigned for ${mac} — set it under Room Buttons"; return }
def known = state.discoveredRooms?.getAt(mac) ?: []
def room = known.find { it.id.toString() == roomIdStr }
if (!room) { log.warn "Wyze Vacuum: slot ${slot} room (id ${roomIdStr}) not found for ${mac} — try Discover Rooms again"; return }
ifDebug("cleanRoomSlot(${mac}, ${slot}) -> ${room.name}")
dispatchRoomClean(mac, [room])
}
def cleanSpecificRooms(String mac, String roomNamesCsv) {
def rooms = state.discoveredRooms?.getAt(mac)
if (!rooms) { log.warn "Wyze Vacuum: no discovered rooms for ${mac} — click Discover Rooms first"; return }
def wanted = roomNamesCsv.split(",").collect { it.trim().toLowerCase() }.findAll { it }
def matched = rooms.findAll { it.name?.toLowerCase() in wanted }
if (!matched) { log.warn "Wyze Vacuum: no rooms matched '${roomNamesCsv}' for ${mac}. Known rooms: ${rooms.collect { it.name }}"; return }
dispatchRoomClean(mac, matched)
}
def cleanNextRooms(String mac) {
def roomIds = settings["rotationRooms_${mac}"]
if (!roomIds) { log.warn "Wyze Vacuum: no rotation rooms configured for ${mac}"; return }
// Marks this vacuum as mid-sweep -- once the dispatched batch genuinely
// finishes, continueSweepIfNeeded() will automatically call this again
// for the next batch as long as something's still actually due, so a
// single trigger works through the whole due-list instead of requiring
// a fresh call per room. dock()/pause()/start() clear this flag again.
state.rotationSweepActive = state.rotationSweepActive ?: [:]
boolean freshSweepStart = !(state.rotationSweepActive[mac])
state.rotationSweepActive[mac] = true
// Only stamped on a genuine fresh start, not on continueSweepDispatch's
// re-call for the next batch -- continuous mode's time limit (see
// continueSweepIfNeeded) is measured from when the whole sweep began,
// not from the most recent batch.
if (freshSweepStart) {
state.rotationSweepStartedAt = state.rotationSweepStartedAt ?: [:]
state.rotationSweepStartedAt[mac] = now()
}
def chosen = previewNextRooms(mac)
if (!chosen) { ifDebug("cleanNextRooms(${mac}): nothing to clean"); return }
chosen = roomsBatteryCanCover(mac, chosen)
if (!chosen) {
// Nothing here is worth dispatching on the charge available. Ending
// the sweep rather than leaving it set matters -- an active sweep flag
// reads as outstanding work (see hasWorkPending), and the rooms stay
// due regardless, so the next trigger picks them up normally.
endRotationSweep(mac)
return
}
dispatchRoomClean(mac, chosen)
}
// Whether there's enough charge to be worth starting these rooms at all, and
// if not, how much of the batch is worth starting.
//
// This is NOT a second opinion on Wyze's own ~8% return-to-dock threshold --
// that answers "when do I need to head home," mid-run, and is left entirely
// alone. This answers a question the firmware never asks: "is it worth setting
// out in the first place?" Confirmed live (9/14): the sweep dispatched a room
// at 8% battery, and the vacuum simply never acted on the command -- not on
// the first try, and not on the automatic retry either. It cost a dispatch, a
// retry, a spurious "never started, worth checking it's not stuck" alert, and
// left the room looking untouched. The firmware had already decided the job
// was pointless; this just stops asking.
//
// Rooms are dropped from the end of the batch (least overdue first) until what
// remains fits, so a partial run still happens when it can.
private List roomsBatteryCanCover(String mac, List rooms) {
// Deliberately an explicit false check, not `?: true` -- Groovy Truth
// treats false as falsy, so the elvis would hand back the `true` default
// for a setting the user had explicitly turned off, making the opt-out do
// nothing. Same trap as the mode:0 bug fixed in 1.18.1.
if (settings["requireBatteryForRoom_${mac}"] == false) return rooms
def d = getChildDevice(mac)
def battery = toInt(d?.currentValue("battery"))
if (battery == null) return rooms // no reading to judge by -- don't block on a guess
def candidates = new ArrayList(rooms)
while (candidates) {
def needed = batteryNeededFor(mac, candidates.collect { it.id as Integer })
if (battery >= needed) {
if (candidates.size() < rooms.size()) {
def dropped = rooms.findAll { !(it in candidates) }.collect { it.name }
log.info "Wyze Vacuum ${mac}: battery ${battery}% covers ${candidates.collect { it.name }} but not ${dropped} -- those stay due for next time"
}
return candidates
}
// A single room needing more than a full charge can never satisfy this
// check, so refusing it would drop it out of rotation permanently and
// silently -- the room would just sit on "already due" forever with
// nothing but a log line. Not hypothetical: Living Room needs ~90% at
// the drain rate learned so far, and crosses 100% if that rate rises
// about 13%, which one carpeted run at high suction could do. Take it
// on a nearly-full battery instead and let the vacuum's own
// charge-and-resume finish the job -- that firmware behavior exists
// for precisely this case.
if (candidates.size() == 1 && needed > 100 && battery >= NEARLY_FULL_BATTERY_PCT) {
log.info "Wyze Vacuum ${mac}: '${candidates[0].name}' needs about ${needed}% for its ${Math.round(roomEstimateMinutes(mac, candidates[0].id as Integer))} min, which is more than one charge -- starting it at ${battery}% anyway and letting the vacuum charge and resume, rather than never cleaning it"
return candidates
}
// Trimming only ever drops from the end, never the front: the list is
// ordered most-overdue-first, so the neediest room keeps its place and
// the sweep waits for charge rather than spending it on a lesser room.
// That's deliberate -- it's what stops a big room being starved by
// small ones that keep fitting.
candidates.remove(candidates.size() - 1)
}
def first = rooms[0]
def needed = batteryNeededFor(mac, [first.id as Integer])
def oneCharge = needed > 100 ? " -- more than one charge, so it waits for a nearly-full battery" : ""
log.info "Wyze Vacuum ${mac}: battery ${battery}% won't cover '${first.name}' (needs about ${needed}% for its ${Math.round(roomEstimateMinutes(mac, first.id as Integer))} min)${oneCharge} -- not starting it, it stays due for the next trigger"
return []
}
// Charge needed to run these rooms and still have something left on arrival at
// the dock. The reserve sits above Wyze's own ~8% return threshold so the
// vacuum isn't asked to finish right at the edge of it.
private Integer batteryNeededFor(String mac, List roomIds) {
double minutes = (roomIds ?: []).sum { id -> roomEstimateMinutes(mac, id as Integer) } ?: 0.0
return Math.ceil(minutes * batteryDrainPerMin(mac) + 10) as Integer
}
private double roomEstimateMinutes(String mac, Integer roomId) {
def avgMap = state.roomAvgMinutes?.getAt(mac) ?: [:]
return (avgMap[roomId.toString()] ?: 15.0) as Double
}
// Battery used per minute of cleaning, measured from real runs the same way
// room times are. The default is what this vacuum actually showed across two
// full-battery runs (100% -> 10% in 37 min, 100% -> 19% in 36 min); a few real
// runs replace it with whatever a given vacuum and suction setting really do.
private double batteryDrainPerMin(String mac) {
return (state.batteryDrainPerMin?.getAt(mac) ?: 2.3) as Double
}
private void learnBatteryDrain(String mac, Integer elapsedMin, def d) {
if (!elapsedMin || elapsedMin < 5) return // too short to measure anything real
def startPct = state.cleaningStartBattery?.getAt(mac)
def endPct = toInt(d?.currentValue("battery"))
if (startPct == null || endPct == null) return
double used = (startPct as Integer) - endPct
if (used <= 0) return // charged partway through (a mode-11 pause) -- not a clean sample
double rate = used / (elapsedMin as Double)
if (rate < 0.5 || rate > 10.0) return // implausible; ignore rather than poison the average
state.batteryDrainPerMin = state.batteryDrainPerMin ?: [:]
def prev = state.batteryDrainPerMin[mac] as Double
// Same exponential blend the room-time estimates use.
state.batteryDrainPerMin[mac] = prev ? (prev * 0.7 + rate * 0.3) : rate
ifDebug("learnBatteryDrain(${mac}): ${String.format('%.2f', rate)}%/min this run -> ${String.format('%.2f', state.batteryDrainPerMin[mac])}%/min average")
}
// Computes what cleanNextRooms(mac) would pick right now, without dispatching
// anything -- shared by the actual dispatch above and the nextRoomsToClean
// attribute so the two can never drift out of sync with each other.
//
// This runs every poll, but these values usually don't change between polls
// -- only sendEvent when the value actually differs from what's already
// there, so the device's event history isn't filled with a fresh identical
// entry every single poll cycle for values that haven't moved.
private void updateRotationPreviewAttributes(def d, String mac) {
sendEventIfChanged(d, "roomsPendingThisCycle", pendingRoomCount(mac))
def next = previewNextRooms(mac)
sendEventIfChanged(d, "nextRoomsToClean", next ? next.collect { it.name }.join(", ") : "none")
sendEventIfChanged(d, "nextRoomDueAt", nextRoomDueDescription(mac))
}
private void sendEventIfChanged(def d, String name, def value) {
if (d.currentValue(name)?.toString() != value?.toString()) {
d.sendEvent(name: name, value: value)
}
}
// Answers "when does the next room actually become due" -- distinct from
// nextRoomsToClean, which just names whichever room is currently
// least-recently-cleaned (a candidate regardless of due-status). This finds
// the single room with the earliest (lastCleaned + its own cycle length)
// across the rotation list -- mathematically the same room previewNextRooms
// would rank first, since both are driven by the same per-room urgency.
private String nextRoomDueDescription(String mac) {
def roomIds = (settings["rotationRooms_${mac}"] ?: []).collect { it as Integer }
if (!roomIds) return "no rotation rooms configured"
def known = state.discoveredRooms?.getAt(mac) ?: []
def history = state.roomHistory?.getAt(mac) ?: [:]
def nowMs = now()
Integer soonestId = null
Long soonestDueAt = null
roomIds.each { id ->
long last = (history[id.toString()] ?: 0L) as Long
long cycleMs = roomCycleDays(mac, id) * 24L * 60L * 60L * 1000L
long dueAt = last + cycleMs
if (soonestDueAt == null || dueAt < soonestDueAt) {
soonestDueAt = dueAt
soonestId = id
}
}
if (soonestId == null) return "none"
def roomName = known.find { it.id == soonestId }?.name ?: "Room ${soonestId}"
if (soonestDueAt <= nowMs) return "${roomName} (already due)"
def dateStr = new Date(soonestDueAt).format("MM/dd/yyyy HH:mm", location.timeZone)
return "${roomName} (due ${dateStr})"
}
// Which cycle length applies to a given room -- the shorter high-traffic
// cycle if it's been marked as such, otherwise the normal/low-traffic one.
private Integer roomCycleDays(String mac, Integer roomId) {
def highSet = (settings["highTrafficRooms_${mac}"] ?: []).collect { it as Integer } as Set
if (roomId in highSet) {
return (settings["rotationCycleDaysHighTraffic_${mac}"] ?: 3) as Integer
}
return (settings["rotationCycleDays_${mac}"] ?: 7) as Integer
}
private List previewNextRooms(String mac) {
def roomIds = (settings["rotationRooms_${mac}"] ?: []).collect { it as Integer }
if (!roomIds) return []
// Exclude whatever's already actively being cleaned -- its "last
// cleaned" timestamp won't update until that run actually finishes, so
// without this a repeat call (or this preview, mid-run) would just
// re-pick the same rooms already in progress instead of advancing to
// the next group. Confirmed live: calling cleanNextRooms() again while
// a batch was still running re-dispatched the identical rooms and
// visibly did nothing, since the vacuum was already doing exactly that.
def inProgress = (state.activeCleanRun?.getAt(mac)?.roomIds ?: []) as Set
roomIds = roomIds.findAll { !(it in inProgress) }
if (!roomIds) return []
def known = state.discoveredRooms?.getAt(mac) ?: []
def history = state.roomHistory?.getAt(mac) ?: [:]
def nowMs = now()
// Sort by how overdue each room is *relative to its own cycle length*,
// not raw last-cleaned time -- a high-traffic room on a 3-day cycle
// reaches "fully due" (fraction 1.0) three times as fast as a
// normal-traffic room on a 7-day cycle, so it naturally rises to the
// top of the pick order more often without a hard-gated separate queue.
// Equivalent to the old plain oldest-first sort when every room shares
// the same cycle length.
def urgency = { Integer id ->
def last = (history[id.toString()] ?: 0L) as Long
def cycleMs = roomCycleDays(mac, id) * 24L * 60L * 60L * 1000L
cycleMs > 0 ? (nowMs - last) / (double) cycleMs : 0.0
}
def candidates = roomIds.sort { a, b -> urgency(b) <=> urgency(a) }
def mode = settings["rotationMode_${mac}"] ?: "count"
def chosenIds = []
if (mode == "time") {
def budgetMin = (settings["rotationMinutes_${mac}"] ?: 30) as Integer
def avgMap = state.roomAvgMinutes?.getAt(mac) ?: [:]
def used = 0.0
candidates.each { id ->
if (used >= budgetMin && chosenIds) return
chosenIds << id
used += (avgMap[id.toString()] ?: 15.0) as Double
}
} else {
def n = (settings["rotationCount_${mac}"] ?: 2) as Integer
chosenIds = candidates.take(n)
}
return chosenIds.collect { id -> known.find { it.id == id } ?: [id: id, name: "Room ${id}"] }
}
private void dispatchRoomClean(String mac, List rooms) {
def ids = rooms.collect { it.id as Integer }
ifDebug("dispatchRoomClean(${mac}): ${rooms.collect { it.name }} (ids=${ids})")
state.activeCleanRun = state.activeCleanRun ?: [:]
state.activeCleanRun[mac] = [roomIds: ids, startedAt: now()]
state.commandInterrupt?.remove(mac) // a new dispatch supersedes any earlier stop
state.resumeCancelled?.remove(mac) // ...as does it supersede an earlier cancellation
rescheduleDynamicPoll() // switch to fast polling immediately, don't wait on a poll to confirm "Cleaning" first
venusControl(mac, 0, 1, ids) // GLOBAL_SWEEPING / START, scoped to rooms
// Rooms are NOT marked cleaned here — only once the run actually ends
// (see finishActiveCleanRun), so an interrupted run doesn't skip whatever
// didn't get done. This just reflects what was targeted, for quick feedback.
def d = getChildDevice(mac)
d?.sendEvent(name: "lastCleanedRooms", value: rooms.collect { it.name }.join(", "))
pollVacuum(mac)
}
private void markRoomsCleaned(String mac, List roomIds) {
if (!roomIds) return
state.roomHistory = state.roomHistory ?: [:]
def h = state.roomHistory[mac] ?: [:]
roomIds.each { id -> h[id.toString()] = now() }
state.roomHistory[mac] = h
}
// Manually corrects rotation history without actually cleaning anything --
// for when a room was genuinely cleaned (by hand, or by a run whose
// completion never got recorded due to a bug) but the rotation doesn't
// know it, so it keeps getting picked first ahead of rooms that are
// actually more overdue.
def markRoomsCleanedByName(String mac, String roomNamesCsv) {
def known = state.discoveredRooms?.getAt(mac) ?: []
if (!known) { log.warn "Wyze Vacuum: no discovered rooms for ${mac} — click Discover Rooms first"; return }
def wanted = roomNamesCsv.split(",").collect { it.trim().toLowerCase() }.findAll { it }
def matched = known.findAll { it.name?.toLowerCase() in wanted }
if (!matched) { log.warn "Wyze Vacuum: no rooms matched '${roomNamesCsv}' for ${mac}. Known rooms: ${known.collect { it.name }}"; return }
def ids = matched.collect { it.id as Integer }
markRoomsCleaned(mac, ids)
def d = getChildDevice(mac)
d?.sendEvent(name: "lastCleanedRooms", value: matched.collect { it.name }.join(", "))
if (d) updateRotationPreviewAttributes(d, mac)
ifDebug("markRoomsCleanedByName(${mac}): manually marked ${matched.collect { it.name }} as cleaned")
}
// Called once a room-scoped clean transitions out of "Cleaning". Wyze doesn't
// tell us which specific rooms finished, so we infer it: walk the dispatched
// rooms in order and consume elapsedMin against each room's known/estimated
// duration. A room only counts as done if its *full* estimate fit inside the
// time that elapsed — so an interrupted run under-credits rather than
// over-credits, and whatever didn't get done stays eligible next time. The
// time-estimate average is only refined when the whole batch completed
// cleanly, so a partial run doesn't skew future time-budget estimates.
private Map finishActiveCleanRun(String mac, Integer elapsedMin, String newStatus, def modeCode = null, boolean interrupted = false) {
def run = state.activeCleanRun?.getAt(mac)
if (!run) return [:]
def rooms = run.roomIds ?: []
if (modeSignalsResume(modeCode)) {
// Not a real finish -- the vacuum itself intends to pick this exact
// job back up once charged (confirmed live, see modeSignalsResume).
// Leave the run active (still excluded from re-picking, still not
// credited) and carry this segment's elapsed time forward so the
// eventual real finish gets the full total instead of just
// whichever segment happened to be measured last.
state.activeCleanRun[mac] = run + [
pausedElapsedMin: ((run.pausedElapsedMin ?: 0) as Integer) + (elapsedMin ?: 0),
pausedAt: now()
]
ifDebug("finishActiveCleanRun(${mac}): mode ${modeCode} signals the vacuum intends to resume -- not crediting yet, carrying forward ${state.activeCleanRun[mac].pausedElapsedMin} min so far")
return [:]
}
Integer totalElapsed = (elapsedMin ?: 0) + ((run.pausedElapsedMin ?: 0) as Integer)
state.roomAvgMinutes = state.roomAvgMinutes ?: [:]
def avgMap = state.roomAvgMinutes[mac] ?: [:]
// Rough job-effectiveness read: how much of the *whole* dispatched batch's
// expected time actually elapsed, regardless of which individual rooms end
// up credited below. This doesn't need a per-room completion record --
// just the learned/estimated minutes for each room that was targeted --
// so it works even for rooms whose exact finish point is ambiguous.
double expectedTotal = rooms.sum { id -> (avgMap[id.toString()] ?: 15.0) as Double } ?: 0.0
Integer completenessPct = expectedTotal > 0 ? Math.min(100, Math.round(totalElapsed / expectedTotal * 100)) as Integer : null
if (completenessPct != null) {
getChildDevice(mac)?.sendEvent(name: "lastRunCompleteness", value: completenessPct, unit: "%")
}
ifDebug("finishActiveCleanRun(${mac}): elapsedMin=${totalElapsed} expectedTotal=${expectedTotal}min across ${rooms.size()} room(s) -> completeness=${completenessPct}%")
def completed
def incomplete
if (rooms.size() == 1) {
// No batch to split -- whatever happened, happened to this one room,
// so there's no need to guess against an estimate. Paused/Error is
// the only genuinely ambiguous exit (could still resume); anything
// else (Docked/Returning/Standby) means this room's pass is over.
//
// ...unless this side is what ended it. A dock()/pause() command --
// most often an automation firing on "someone came home" -- looks
// exactly like a natural finish in the vacuum's own status, so
// without the `interrupted` flag a room cut short at minute 3 of 35
// got fully credited (dropping it from rotation for a whole cycle)
// *and* had its learned time overwritten with the truncated value,
// skewing every future time-budget run. A commanded stop is never a
// finish, however long it ran.
boolean genuinelyFinished = !(newStatus == "Paused" || newStatus == "Error") && !interrupted && totalElapsed > 0
completed = genuinelyFinished ? rooms : []
incomplete = genuinelyFinished ? [] : rooms
if (genuinelyFinished) {
def roomName = (state.discoveredRooms?.getAt(mac) ?: []).find { it.id == rooms[0] }?.name ?: "Room ${rooms[0]}"
log.info "Wyze Vacuum ${mac}: '${roomName}' took ${totalElapsed} min to clean"
}
} else {
double remaining = totalElapsed
completed = []
rooms.each { id ->
double est = (avgMap[id.toString()] ?: 15.0) as Double
if (remaining >= est) {
completed << id
remaining -= est
}
}
incomplete = rooms - completed
}
markRoomsCleaned(mac, completed)
if (incomplete.isEmpty() && rooms) {
if (rooms.size() == 1) {
// A single-room batch is ground truth -- overwrite outright
// rather than blending, same treatment Learning Mode gives.
avgMap[rooms[0].toString()] = totalElapsed as Double
} else {
double perRoom = totalElapsed / (double) rooms.size()
rooms.each { id ->
def key = id.toString()
def prevAvg = avgMap[key]
// exponential moving average so estimates keep improving with real runs
avgMap[key] = prevAvg ? (prevAvg * 0.7 + perRoom * 0.3) : perRoom
}
}
state.roomAvgMinutes[mac] = avgMap
}
def known = state.discoveredRooms?.getAt(mac) ?: []
def completedNames = completed.collect { id -> known.find { it.id == id }?.name ?: "Room ${id}" }
def incompleteNames = incomplete.collect { id -> known.find { it.id == id }?.name ?: "Room ${id}" }
if (completedNames) {
getChildDevice(mac)?.sendEvent(name: "lastCleanedRooms", value: completedNames.join(", "))
}
state.activeCleanRun.remove(mac)
ifDebug("finishActiveCleanRun(${mac}): elapsedMin=${totalElapsed} completed=${completed} incomplete=${incomplete}")
return [completedNames: completedNames, incompleteNames: incompleteNames]
}
// =================== Room-timing learning mode ===================
//
// Cleans the rotation rooms (or all discovered rooms, if none are selected
// for rotation yet) one at a time and records each one's directly-measured
// clean time -- a ground-truth reading rather than an inferred split of a
// multi-room batch. Runs across many poll cycles: each room dispatch sets a
// single-room activeCleanRun, and handleLearningRoomEnd advances to the next
// room once that one's Cleaning session ends.
def startLearningMode(String mac) {
def d = getChildDevice(mac)
if (d?.currentValue("status") == "Cleaning") {
log.warn "Wyze Vacuum: ${mac} is already cleaning — dock or pause it before starting learning mode."
return
}
def rooms = (settings["rotationRooms_${mac}"] ?: []).collect { it as Integer }
if (!rooms) {
rooms = (state.discoveredRooms?.getAt(mac) ?: []).collect { it.id as Integer }
}
if (!rooms) { log.warn "Wyze Vacuum: no rooms to learn for ${mac} — discover/select rooms first"; return }
def known = state.discoveredRooms?.getAt(mac) ?: []
def firstId = rooms[0]
def firstRoom = known.find { it.id == firstId } ?: [id: firstId, name: "Room ${firstId}"]
state.learningMode = state.learningMode ?: [:]
state.learningMode[mac] = [queue: rooms.drop(1)]
ifDebug("startLearningMode(${mac}): queue=${rooms}")
dispatchLearningRoom(mac, firstRoom)
}
def cancelLearningMode(String mac) {
state.learningMode?.remove(mac)
getChildDevice(mac)?.sendEvent(name: "learningStatus", value: "Idle")
ifDebug("cancelLearningMode(${mac})")
}
private void dispatchLearningRoom(String mac, Map room) {
def id = room.id as Integer
state.activeCleanRun = state.activeCleanRun ?: [:]
state.activeCleanRun[mac] = [roomIds: [id], startedAt: now(), learning: true]
rescheduleDynamicPoll() // switch to fast polling immediately, don't wait on a poll to confirm "Cleaning" first
venusControl(mac, 0, 1, [id]) // GLOBAL_SWEEPING / START, scoped to this one room
def remaining = state.learningMode?.getAt(mac)?.queue?.size() ?: 0
def d = getChildDevice(mac)
d?.sendEvent(name: "lastCleanedRooms", value: "Learning: ${room.name}")
d?.sendEvent(name: "learningStatus", value: "Learning ${room.name} (${remaining} more queued)")
pollVacuum(mac)
}
// Fires once the current learning-mode room's Cleaning session ends. Only a
// clean exit (not "Paused"/"Error") is trusted as a real measurement --
// anything else aborts the whole learning sequence rather than guessing.
private void handleLearningRoomEnd(String mac, Map run, Integer elapsedMin, String newStatus) {
def d = getChildDevice(mac)
def roomId = run.roomIds ? (run.roomIds[0] as Integer) : null
if (newStatus == "Paused" || newStatus == "Error") {
sendVacuumNotification("${d?.displayName ?: mac} learning mode stopped early — ${newStatus == "Paused" ? "cleaning was paused" : "the vacuum reported an error"} before this room's measurement finished.")
d?.sendEvent(name: "learningStatus", value: "Stopped early")
state.activeCleanRun.remove(mac)
state.learningMode?.remove(mac)
return
}
if (roomId != null && elapsedMin && elapsedMin > 0) {
// A dedicated single-room pass is ground truth -- overwrite outright
// rather than blending it in gradually like the multi-room EMA does.
state.roomAvgMinutes = state.roomAvgMinutes ?: [:]
def avgMap = state.roomAvgMinutes[mac] ?: [:]
avgMap[roomId.toString()] = elapsedMin as Double
state.roomAvgMinutes[mac] = avgMap
markRoomsCleaned(mac, [roomId])
ifDebug("learning mode (${mac}): room ${roomId} measured at ${elapsedMin} min")
}
state.activeCleanRun.remove(mac)
def queue = state.learningMode?.getAt(mac)?.queue ?: []
if (!queue) {
sendVacuumNotification("${d?.displayName ?: mac} finished learning room times for all rooms.")
d?.sendEvent(name: "learningStatus", value: "Idle")
state.learningMode?.remove(mac)
return
}
def nextId = queue[0]
state.learningMode[mac] = [queue: queue.drop(1)]
def known = state.discoveredRooms?.getAt(mac) ?: []
def nextRoom = known.find { it.id == nextId } ?: [id: nextId, name: "Room ${nextId}"]
ifDebug("learning mode (${mac}): advancing to ${nextRoom.name}")
dispatchLearningRoom(mac, nextRoom)
}
private Integer pendingRoomCount(String mac) {
def roomIds = settings["rotationRooms_${mac}"]
if (!roomIds) return 0
def history = state.roomHistory?.getAt(mac) ?: [:]
def nowMs = now()
return roomIds.count { id ->
def rid = id as Integer
def cutoff = nowMs - (roomCycleDays(mac, rid) * 24L * 60L * 60L * 1000L)
(history[id.toString()] ?: 0L) < cutoff
}
}
// =================== Map / room discovery ===================
private void discoverRooms(String mac) {
def resp = venusRequest("GET", "/plugin/venus/memory_map/current_map", [did: mac])
def blobB64 = resp?.data?.map
if (!blobB64) {
state["roomError_${mac}"] = "No active map found. Make sure the vacuum has completed at least one full clean."
return
}
try {
byte[] compressed = blobB64.decodeBase64()
byte[] raw = zlibDecompress(compressed)
def rooms = extractRoomsFromProtobuf(raw)
if (!rooms) {
state["roomError_${mac}"] = "Map found but no named rooms yet. Label rooms in the Wyze app first, then try again."
return
}
state.discoveredRooms = state.discoveredRooms ?: [:]
state.discoveredRooms[mac] = rooms
state["roomError_${mac}"] = null
ifDebug("discoverRooms(${mac}): ${rooms}")
} catch (e) {
state["roomError_${mac}"] = "Failed to parse map data: ${e.message}"
log.error "Wyze Vacuum discoverRooms(${mac}) error: ${e}"
}
}
private byte[] zlibDecompress(byte[] data) {
def inflater = new Inflater()
inflater.setInput(data)
def out = new ByteArrayOutputStream(Math.max(256, data.length * 4))
byte[] buf = new byte[4096]
while (!inflater.finished()) {
int n = inflater.inflate(buf)
if (n == 0) {
if (inflater.needsInput() || inflater.needsDictionary()) break
}
out.write(buf, 0, n)
}
inflater.end()
return out.toByteArray()
}
// Minimal protobuf wire-format reader — only pulls what's needed (room id + name)
// out of Wyze's zlib-compressed map blob. Field 12 at the top level is a repeated
// RoomDataInfo submessage; within it, field 1 is roomId (varint) and field 2 is
// roomName (length-delimited UTF-8 bytes). Everything else is skipped.
private List readVarint(byte[] buf, int pos) {
long result = 0
int shift = 0
int p = pos
while (true) {
int b = buf[p] & 0xFF
result |= ((long) (b & 0x7F)) << shift
p++
if ((b & 0x80) == 0) break
shift += 7
}
return [result, p]
}
private Map parseProtoFields(byte[] buf, int start, int end) {
def fields = [:]
int pos = start
while (pos < end) {
def tag = readVarint(buf, pos)
pos = tag[1]
int fieldNum = (int) ((long) tag[0] >>> 3)
int wireType = (int) ((long) tag[0] & 0x7)
switch (wireType) {
case 0:
def v = readVarint(buf, pos)
pos = v[1]
def list0 = fields[fieldNum] ?: []
list0 << v[0]
fields[fieldNum] = list0
break
case 1:
pos += 8
break
case 2:
def len = readVarint(buf, pos)
pos = len[1]
int sliceEnd = (int) (pos + (long) len[0])
byte[] slice = new byte[sliceEnd - pos]
for (int i = 0; i < slice.length; i++) slice[i] = buf[pos + i]
pos = sliceEnd
def list2 = fields[fieldNum] ?: []
list2 << slice
fields[fieldNum] = list2
break
case 5:
pos += 4
break
default:
pos = end // unknown wire type — bail out rather than loop forever
}
}
return fields
}
private List extractRoomsFromProtobuf(byte[] buf) {
def top = parseProtoFields(buf, 0, buf.length)
def roomBlobs = top[12] ?: []
def rooms = []
roomBlobs.each { blob ->
def rf = parseProtoFields((byte[]) blob, 0, ((byte[]) blob).length)
def idList = rf[1]
def nameList = rf[2]
if (idList && nameList) {
rooms << [id: ((long) idList[0]) as Integer, name: new String((byte[]) nameList[0], "UTF-8")]
}
}
return rooms
}
// =================== Wyze API — signed Venus (vacuum) calls ===================
private Map venusRequest(String method, String path, Map query = [:], Map bodyMap = null, boolean retry = true) {
if (!state.wyzeAccessToken) return null
def nonce = now()
def requestId = md5Hex(md5Hex(nonce.toString()))
def signingKey = md5Hex("${state.wyzeAccessToken}${VENUS_SALT}")
def headers = [
"access_token": state.wyzeAccessToken,
"requestid" : requestId,
"appid" : VENUS_APP_ID,
"appinfo" : "wyze_android_${APP_VERSION}",
"phoneid" : state.wyzePhoneId,
"User-Agent" : "wyze_android_${APP_VERSION}",
"Accept-Encoding": "gzip"
]
Map result = null
try {
if (method == "GET") {
def qp = new TreeMap()
query.each { k, v -> qp[k] = v?.toString() }
qp["nonce"] = nonce.toString()
def sigString = qp.collect { k, v -> "${k}=${v}" }.join("&")
headers["signature2"] = hmacMd5Hex(signingKey, sigString)
httpGet([
uri: VENUS_BASE, path: path, query: qp, headers: headers, timeout: 20
]) { resp -> result = resp.data instanceof Map ? resp.data : new JsonSlurper().parseText(resp.data.text) }
} else {
def payload = new LinkedHashMap(bodyMap ?: [:])
payload["nonce"] = nonce.toString()
def bodyJson = JsonOutput.toJson(payload)
headers["signature2"] = hmacMd5Hex(signingKey, bodyJson)
httpPost([
uri: VENUS_BASE, path: path, requestContentType: "application/json",
headers: headers, body: bodyJson, timeout: 20
]) { resp -> result = resp.data instanceof Map ? resp.data : new JsonSlurper().parseText(resp.data.text) }
}
} catch (groovyx.net.http.HttpResponseException e) {
if (retry && e.statusCode in [401, 403] && refreshWyzeToken()) {
return venusRequest(method, path, query, bodyMap, false)
}
log.error "Wyze Venus ${method} ${path} failed (${e.statusCode}): ${e.message}"
return null
} catch (e) {
log.error "Wyze Venus ${method} ${path} error: ${e}"
return null
}
// Wyze signals some failures (e.g. an expired access token) with an HTTP
// 200 and an error code/message in the body instead of a real 401/403 --
// confirmed live: {code:2001, message:"Access token error"}. That never
// threw HttpResponseException, so it was silently failing with no retry.
if (retry && isAuthErrorResponse(result)) {
ifDebug("Wyze Venus ${method} ${path} got an in-body auth error (${result?.code}: ${result?.message}), refreshing token and retrying once")
if (refreshWyzeToken()) {
return venusRequest(method, path, query, bodyMap, false)
}
}
return result
}
// =================== Wyze API — unsigned general (api.wyzecam.com) calls ===================
private Map apiWyzeRequest(String path, Map extraBody = [:], boolean retry = true) {
if (!state.wyzeAccessToken) return null
def payload = new LinkedHashMap(extraBody)
payload["access_token"] = state.wyzeAccessToken
payload["app_name"] = "com.hualai"
payload["app_ver"] = "com.hualai___${APP_VERSION}"
payload["app_version"] = APP_VERSION
payload["phone_id"] = state.wyzePhoneId
payload["phone_system_type"] = "2"
payload["sc"] = WYZE_SC
payload["ts"] = now()
Map result = null
try {
httpPost([
uri: API_BASE, path: path, requestContentType: "application/json",
headers: ["Connection": "keep-alive"], body: JsonOutput.toJson(payload), timeout: 20
]) { resp -> result = resp.data instanceof Map ? resp.data : new JsonSlurper().parseText(resp.data.text) }
} catch (groovyx.net.http.HttpResponseException e) {
if (retry && e.statusCode in [401, 403] && refreshWyzeToken()) {
return apiWyzeRequest(path, extraBody, false)
}
log.error "Wyze API ${path} failed (${e.statusCode}): ${e.message}"
return null
} catch (e) {
log.error "Wyze API ${path} error: ${e}"
return null
}
if (retry && isAuthErrorResponse(result)) {
ifDebug("Wyze API ${path} got an in-body auth error (${result?.code}: ${result?.message}), refreshing token and retrying once")
if (refreshWyzeToken()) {
return apiWyzeRequest(path, extraBody, false)
}
}
return result
}
// =================== Crypto helpers ===================
private String md5Hex(String s) {
return MessageDigest.getInstance("MD5").digest(s.getBytes("UTF-8")).encodeHex().toString()
}
// HMAC-MD5 implemented directly against MessageDigest (no javax.crypto dependency)
private String hmacMd5Hex(String keyHexString, String message) {
int blockSize = 64
byte[] key = keyHexString.getBytes("UTF-8")
if (key.length > blockSize) key = MessageDigest.getInstance("MD5").digest(key)
byte[] paddedKey = new byte[blockSize]
for (int i = 0; i < key.length; i++) paddedKey[i] = key[i] // rest defaults to 0 — zero-padding for free
byte[] oKeyPad = new byte[blockSize]
byte[] iKeyPad = new byte[blockSize]
for (int i = 0; i < blockSize; i++) {
oKeyPad[i] = (byte) (paddedKey[i] ^ (byte) 0x5c)
iKeyPad[i] = (byte) (paddedKey[i] ^ (byte) 0x36)
}
def md = MessageDigest.getInstance("MD5")
md.update(iKeyPad)
md.update(message.getBytes("UTF-8"))
byte[] innerHash = md.digest()
md = MessageDigest.getInstance("MD5")
md.update(oKeyPad)
md.update(innerHash)
return md.digest().encodeHex().toString()
}
// =================== Utility ===================
private Integer toInt(def v) {
if (v == null) return null
try { return Math.round(v.toString().toDouble()) as Integer }
catch (e) { return null }
}
def logsOff() {
log.warn "Wyze Vacuum Connect: debug logging disabled"
app.updateSetting("isDebug", [value: "false", type: "bool"])
}
private void ifDebug(String msg) {
if (settings.isDebug) log.debug "Wyze Vacuum Connect: ${msg}"
}