/**
* Wyze Vacuum Connect App
*
* 1.34.0 - 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
// Battery model (1.32.0). Cleaning doesn't drain linearly from a standing start:
// the first stretch after leaving the dock costs a fixed chunk, then it settles
// into a steady %/min. Fitting both of this vacuum's real full-battery runs
// (Living Room: 100% -> 19% in 36 min, 100% -> 10% in 37 min) together with a
// 10-minute Hallway run (100% -> 69%) gives about 10% fixed + about 2%/min.
// A single flat %/min can't describe that -- learned from short runs it comes
// out near 3.3%/min and wildly overstates a long room (130% predicted for a room
// that really uses ~85-90%), which is exactly what blocked Living Room on 10/2.
@Field static final Integer BATTERY_STARTUP_PCT = 10
@Field static final Double BATTERY_DEFAULT_PCT_PER_MIN = 2.1
// Margin kept on top of the predicted use so the vacuum isn't finishing right at
// Wyze's own ~8% return-to-dock threshold.
@Field static final Integer BATTERY_RESERVE_PCT = 10
// A per-room figure is only recorded from a run that began on a nearly-full
// battery: from partway down the curve the startup drop is smaller, so such a run
// would understate what the same room costs when started from a fresh charge.
@Field static final Integer BATTERY_MEASURE_MIN_START_PCT = 85
// Waiting for a recharge (1.33.0) gives up after this long in one go. Charging
// from empty takes about 3 hours, so six means the vacuum isn't actually charging
// -- it's off its dock, unplugged, or offline. This is a safety net, not a
// feature: any state that reads as "work outstanding" has to be able to end by
// itself, or the switch latches on (the 1.29.1 bug). The limits that matter to
// the user are the room count and "nothing left due".
@Field static final Integer WAIT_FOR_CHARGE_GIVE_UP_HOURS = 6
// A vacuum Paused, off its dock and not charging for this long is stranded (1.34.0).
@Field static final Integer STRANDED_PAUSED_MINUTES = 30
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 nextIds = nextUp.collect { it.id as Integer }
def allMeasured = nextIds && nextIds.every { batteryUseIsMeasured(mac, it) }
def detail = nextUp
? "Next up is ${nextUp.collect { it.name }.join(', ')}, needing about ${batteryNeededFor(mac, nextIds)}% " +
"(${allMeasured ? 'based on what it actually used last time' : 'an estimate — no full-battery run recorded for it yet'}; " +
"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]) > NEARLY_FULL_BATTERY_PCT
}
def bigNote = bigRooms
? " ${roomNamesFor(mac, bigRooms).join(', ')} needs close to 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 "A room it has cleaned on its own from a full battery is predicted from what it really used. Anything else is estimated as " +
"${BATTERY_STARTUP_PCT}% to get going plus ${String.format('%.1f', drain)}% per minute" +
(learned ? " (measured from this vacuum's own runs)" : " (starting estimate — replaced as real runs are recorded)") +
", with a further ${BATTERY_RESERVE_PCT}% kept in hand 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."
input "waitForCharge_${mac}", "bool",
title: "If the battery can't cover the next room, wait for it to charge and carry on with whatever is still due. The switch stays on while it waits, so turning it off (or docking it) cancels the rest",
defaultValue: false, required: false, submitOnChange: true
if (settings["waitForCharge_${mac}"]) {
input "waitForChargeMaxRooms_${mac}", "number",
title: "Stop after this many rooms per trip, counting the ones cleaned before it had to wait. It also stops as soon as nothing is due any more",
defaultValue: 3, required: true
if (state.rotationSweepWaitingSince?.getAt(mac)) {
def waitingFor = previewNextRooms(mac)
paragraph "Waiting for the battery right now" +
(waitingFor ? " to cover ${waitingFor.collect { it.name }.join(', ')} (needs about ${batteryNeededFor(mac, waitingFor.collect { it.id as Integer })}%, " +
"battery is ${getChildDevice(mac)?.currentValue('battery') ?: '?'}%)" : "") +
" — ${sweepRoomsDone(mac)} of ${sweepRoomCap(mac)} rooms done this trip. Switching the vacuum off cancels it."
}
}
}
input "tripPresenceSensors_${mac}", "capability.presenceSensor",
title: "Presence sensors (optional) — a backstop in case your arrival rule doesn't fire. If a trip started with all of these away and any of them is home when it's about to carry on, the trip ends instead. A trip you start while someone is home is never affected",
multiple: true, required: false
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()
checkSweepWaiting(mac)
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
// Paused or errored means the dispatch DID take effect -- the vacuum left its
// dock -- and then something stopped it. Treating that as "never started" is
// wrong twice over: the retry below re-sends the start command to a vacuum
// that has just been stopped on purpose, and the give-up below tells the
// user it never started. Confirmed live (10/6): a person stopped the vacuum
// by hand about a minute after a dispatch (mode 4, charge_state 0), and two
// minutes later this code re-sent the command anyway, then sent a "never
// started" alert for a vacuum that had been running. The poll interval is
// why Cleaning was never observed to set everConfirmedCleaning: it went from
// the dock to Paused between two polls.
//
// Whoever stopped it has made the decision, so the trip ends here too. The
// room stays uncredited and due, same as any interrupted run.
def stoppedStatus = state.lastKnownStatus?.getAt(mac)
if (stoppedStatus in ["Paused", "Error"]) {
log.info "Wyze Vacuum ${mac}: the dispatched room-clean left the dock and is now ${stoppedStatus} -- it was stopped outside Hubitat, so not retrying and ending the trip (the room stays due)"
state.activeCleanRun.remove(mac)
endRotationSweep(mac)
return
}
// 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)
state.rotationSweepWaitingSince?.remove(mac)
state.rotationSweepRooms?.remove(mac)
state.rotationSweepStartedAway?.remove(mac)
}
// Presence backstop (1.34.0). The app never needs to know who's home as long as
// the user's arrival rule turns the vacuum off -- but that rule is outside the
// app, and on 10/6 it never sent anything: no dock()/pause()/start() line from
// 9:06 to 2:33, so a trip that began with everyone out resumed itself twice
// after someone was home, and a person had to stop it by hand. If the user
// names presence sensors, the app checks them itself at the moments it would
// otherwise carry on unattended.
//
// Only a trip that STARTED with every named sensor away is guarded. A trip the
// user starts on purpose while they're home must not be cancelled the moment it
// has to wait for a recharge.
private List sweepPresenceSensors(String mac) {
return (settings["tripPresenceSensors_${mac}"] ?: []) as List
}
private void snapshotTripPresence(String mac) {
def sensors = sweepPresenceSensors(mac)
state.rotationSweepStartedAway = state.rotationSweepStartedAway ?: [:]
if (!sensors) { state.rotationSweepStartedAway.remove(mac); return }
def homeAtStart = sensors.findAll { it.currentValue("presence") == "present" }
state.rotationSweepStartedAway[mac] = homeAtStart.isEmpty()
if (!homeAtStart.isEmpty()) {
log.info "Wyze Vacuum ${mac}: ${homeAtStart.collect { it.displayName }.join(' and ')} already home when this trip started, so the presence backstop is off for it"
}
}
// Names of whoever is home now, if this trip began with everyone away; otherwise
// empty, meaning "carry on".
private List homeOnAwayTrip(String mac) {
if (!state.rotationSweepStartedAway?.getAt(mac)) return []
return sweepPresenceSensors(mac).findAll { it.currentValue("presence") == "present" }.collect { it.displayName }
}
// Ends the trip and returns true if someone is home on a trip that began away.
private boolean endTripIfSomeoneHome(String mac) {
def home = homeOnAwayTrip(mac)
if (!home) return false
log.info "Wyze Vacuum ${mac}: ${home.join(' and ')} ${home.size() == 1 ? 'is' : 'are'} home and this trip started with everyone away -- ending it instead of carrying on (anything still due waits for the next trigger)"
endRotationSweep(mac)
return true
}
// "Wait for charge" only means anything alongside the battery check -- with that
// check off there is never a skip to wait out, so the toggle (and the room
// limit that rides along with it) is treated as off rather than left acting
// invisibly behind a hidden setting.
private boolean waitForChargeEnabled(String mac) {
return settings["waitForCharge_${mac}"] == true && settings["requireBatteryForRoom_${mac}"] != false
}
private int sweepRoomCap(String mac) {
return Math.max(1, (settings["waitForChargeMaxRooms_${mac}"] ?: 3) as Integer)
}
private int sweepRoomsDone(String mac) {
return (state.rotationSweepRooms?.getAt(mac) ?: 0) as Integer
}
// Rooms credited since this trip began, including those cleaned before it ever
// had to wait -- the limit is on the whole trip, not just the part after a
// recharge.
private boolean sweepRoomCapReached(String mac) {
return waitForChargeEnabled(mac) && sweepRoomsDone(mac) >= sweepRoomCap(mac)
}
// Whether the sweep still has anything to do. In continuous mode that's any
// room at all; otherwise only rooms that are actually due.
private boolean sweepHasWork(String mac) {
boolean continuous = (settings["rotationContinuousMode_${mac}"] ?: false) as boolean
return continuous ? !previewNextRooms(mac).isEmpty() : pendingRoomCount(mac) > 0
}
// Called every status poll. While a sweep is waiting for the battery, decide
// whether to keep waiting, carry on, or stop -- evaluated quietly so a wait
// doesn't write a "won't cover" line every poll. Presence never comes into it:
// the sweep only waits while the switch is on, and turning the switch off
// (dock() -> endRotationSweep) ends it, so arrival stays in the user's rules.
private void checkSweepWaiting(String mac) {
def since = state.rotationSweepWaitingSince?.getAt(mac)
if (!since) return
if (!state.rotationSweepActive?.getAt(mac)) { // cancelled while waiting
state.rotationSweepWaitingSince.remove(mac)
return
}
// It's meant to be sitting on the dock. If something else has started it or
// stopped it, the wait is moot.
def status = state.lastKnownStatus?.getAt(mac)
if (status in ["Cleaning", "Paused", "Error"]) {
ifDebug("checkSweepWaiting(${mac}): status is ${status} -- no longer waiting")
endRotationSweep(mac)
return
}
double hours = (now() - (since as Long)) / 3600000.0
if (hours >= WAIT_FOR_CHARGE_GIVE_UP_HOURS) {
log.warn "Wyze Vacuum ${mac}: waited ${String.format('%.1f', hours)}h for the battery to recover and it hasn't -- giving up on this trip"
sendVacuumNotification("${getChildDevice(mac)?.displayName ?: mac} gave up waiting to charge after ${String.format('%.0f', hours)} hours -- worth checking it's actually on its dock and charging.")
endRotationSweep(mac)
return
}
if (sweepRoomCapReached(mac)) {
log.info "Wyze Vacuum ${mac}: reached the ${sweepRoomCap(mac)}-room limit for this trip -- not waiting any longer (anything still due waits for the next trigger)"
endRotationSweep(mac)
return
}
if (!sweepHasWork(mac)) {
log.info "Wyze Vacuum ${mac}: nothing is due any more -- no longer waiting for the battery"
endRotationSweep(mac)
return
}
if (endTripIfSomeoneHome(mac)) return
def next = previewNextRooms(mac)
if (next && roomsBatteryCanCover(mac, next, false, true)) {
log.info "Wyze Vacuum ${mac}: battery has recovered enough for ${next.collect { it.name }} -- carrying on"
// runIn, not a direct call: this is an async poll callback and
// venusControl posts synchronously (the 1.5.1 hub-load shape).
runIn(2, "continueSweepDispatch", [data: [mac: mac], overwrite: false])
}
}
// 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.rotationSweepWaitingSince?.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
}
checkStrandedPaused(mac, newStatus, isCharging, d)
}
// Confirmed live (10/6): a hand-stopped vacuum sat Paused, off its dock and not
// charging, from 2:31 PM until at least 7:45 PM while the battery drained from
// 75% to 42% -- and nothing said so, because the check above only knows
// "Standby". A paused vacuum is just as stranded: it will not return to the
// dock by itself, so left alone it runs flat wherever it stopped. Alert once per
// episode (it does not dock the vacuum for you: the person who paused it may
// have a reason, and it may be sitting somewhere a dock command can't help).
private void checkStrandedPaused(String mac, String newStatus, boolean isCharging, def d) {
state.pausedStartedAt = state.pausedStartedAt ?: [:]
state.strandedNotified = state.strandedNotified ?: [:]
if (newStatus == "Paused" && !isCharging) {
if (!state.pausedStartedAt[mac]) state.pausedStartedAt[mac] = now()
double minutesPaused = (now() - (state.pausedStartedAt[mac] as Long)) / 60000.0
if (minutesPaused >= STRANDED_PAUSED_MINUTES && !state.strandedNotified[mac]) {
state.strandedNotified[mac] = true
def batt = d?.currentValue("battery")
def battText = (batt != null) ? " (battery ${batt}% and falling)" : ""
log.warn "Wyze Vacuum ${mac}: paused off its dock for ${Math.round(minutesPaused)} min${battText}"
sendVacuumNotification("${d?.displayName ?: mac} has been paused off its dock for over ${STRANDED_PAUSED_MINUTES} minutes${battText}. A paused vacuum doesn't go home by itself and will run flat where it stopped -- send it to the dock (dock() or the Wyze app) or resume it.")
}
} else {
state.pausedStartedAt.remove(mac)
state.strandedNotified[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
dockVacuumFor(mac, "the low-battery dock threshold")
}
} 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)"
dockVacuumFor(mac, "auto-resume is turned off")
// 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) {
recordRoomBatteryUse(mac, run, elapsedMin, d, newStatus, interrupt != null)
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)
dockVacuumFor(mac, "the continuous-sweep time limit")
return
}
}
}
if (sweepRoomCapReached(mac)) {
log.info "Wyze Vacuum ${mac}: reached the ${sweepRoomCap(mac)}-room limit for this trip -- ending the sweep (anything still due waits for the next trigger)"
endRotationSweep(mac)
return
}
if (endTripIfSomeoneHome(mac)) return
boolean somethingToDispatch = sweepHasWork(mac)
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
// A run already underway means this is a duplicate continuation. Dispatching
// anyway would pick the *next* room (the in-progress one is excluded from
// selection) and start a second one on top of it.
if (state.activeCleanRun?.containsKey(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 ===================
// One info line for every start/pause/dock that reaches the app, saying what it
// actually changed. Until 1.33.1 these were debug-only, so when a vacuum did
// something unexpected the first question -- did the command ever arrive? --
// could only be inferred from poll timing (9/14: an arrival that appeared to send
// nothing, with no way to be sure). Commands are rare, so this costs nothing.
// `reason` is set when the app sent the command to itself rather than a rule.
private void logCommand(String mac, String command, String reason = null) {
def effects = []
if (state.rotationSweepWaitingSince?.getAt(mac)) effects << "cancelled the wait for the battery"
else if (state.rotationSweepActive?.getAt(mac)) effects << "ended the rotation sweep"
if (state.pausedForResumeAt?.getAt(mac)) effects << "cancelled a job paused for charging"
def what = reason ? "${command} sent by the app (${reason})" : "${command} received"
log.info "Wyze Vacuum ${mac}: ${what} -- ${effects ? effects.join(' and ') : 'nothing outstanding to cancel'}"
}
def startVacuum(String mac) {
ifDebug("startVacuum: ${mac}")
logCommand(mac, "start()")
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}")
logCommand(mac, "pause()")
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)
}
// The driver's dock()/off() call this, so it keeps exactly the one-argument
// signature it had before 1.33.1. Anything with a default parameter was avoided
// on purpose: it generates overloads, and how Hubitat dispatches a parent.x()
// call from a child driver onto them can't be tested off-hub. The internal
// callers that want to say why use dockVacuumFor() instead.
def dockVacuum(String mac) {
dockVacuumFor(mac, null)
}
private void dockVacuumFor(String mac, String reason) {
ifDebug("dockVacuum: ${mac}")
logCommand(mac, "dock()", reason)
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) {
log.info "Wyze Vacuum ${mac}: cleanNextRooms() received -- starting a trip"
state.rotationSweepStartedAt = state.rotationSweepStartedAt ?: [:]
state.rotationSweepStartedAt[mac] = now()
// A new trip starts its room count and any wait from scratch.
state.rotationSweepRooms = state.rotationSweepRooms ?: [:]
state.rotationSweepRooms[mac] = 0
state.rotationSweepWaitingSince?.remove(mac)
snapshotTripPresence(mac)
}
def chosen = previewNextRooms(mac)
if (!chosen) { ifDebug("cleanNextRooms(${mac}): nothing to clean"); return }
// Waiting only makes sense for a vacuum that is on its dock charging --
// one stranded elsewhere isn't going to get any more battery by waiting.
boolean canWait = waitForChargeEnabled(mac) && state.lastKnownStatus?.getAt(mac) == "Docked"
chosen = roomsBatteryCanCover(mac, chosen, canWait)
if (!chosen) {
if (canWait) {
// Keep the sweep alive instead of ending it. That is what keeps the
// switch on while it charges (see hasWorkPending), so the arrival
// automation's off() still has something to cancel. The room stays due.
state.rotationSweepWaitingSince = state.rotationSweepWaitingSince ?: [:]
if (!state.rotationSweepWaitingSince[mac]) state.rotationSweepWaitingSince[mac] = now()
state.rotationSweepPending?.put(mac, false)
return
}
// 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, boolean willWait = false, boolean quiet = false) {
// 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() && !quiet) {
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 nearly-full battery can never be
// satisfied by waiting for a fuller one, 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. Take it once the
// battery is nearly full and let the vacuum's own charge-and-resume finish
// the job -- that firmware behavior exists for precisely this case. The
// cutoff is the nearly-full mark rather than 100% so there isn't a cliff
// where a room needing 98% is refused at 97% yet one needing 102% starts
// at 95%.
if (candidates.size() == 1 && needed > NEARLY_FULL_BATTERY_PCT && battery >= NEARLY_FULL_BATTERY_PCT) {
if (!quiet) 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 close to a full charge -- starting it at ${battery}% and letting the vacuum charge and resume if it has to, 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 > NEARLY_FULL_BATTERY_PCT ? " -- close to a full charge, so it waits for a nearly-full battery" : ""
def outcome = willWait ? " -- waiting for it to charge" : "${oneCharge} -- not starting it, it stays due for the next trigger"
if (!quiet) 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)${outcome}"
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) {
def ids = (roomIds ?: []).collect { it as Integer }
return Math.ceil(predictedBatteryUse(mac, ids) + BATTERY_RESERVE_PCT) as Integer
}
// Battery these rooms are expected to use. A room this vacuum has actually
// cleaned on its own is predicted from what it really used last time -- the
// quantity being predicted, measured directly, with no per-minute extrapolation
// across runs of different lengths. Only a room with no history falls back to
// the fixed-startup-plus-per-minute model.
private double predictedBatteryUse(String mac, List ids) {
def measuredFor = state.roomBatteryUsed?.getAt(mac) ?: [:]
double total = 0.0
ids.eachWithIndex { id, i ->
def measured = measuredFor[id.toString()]
double use = measured != null
? (measured as Double)
: BATTERY_STARTUP_PCT + roomEstimateMinutes(mac, id as Integer) * batteryDrainPerMin(mac)
// The startup cost is paid once per run, not once per room, so rooms after
// the first don't carry it again.
if (i > 0) use -= BATTERY_STARTUP_PCT
total += Math.max(use, 0.0)
}
return total
}
// Where the number above came from, for the app page.
private boolean batteryUseIsMeasured(String mac, Integer roomId) {
return state.roomBatteryUsed?.getAt(mac)?.getAt(roomId.toString()) != null
}
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) {
migrateBatteryModel()
return (state.batteryDrainPerMin?.getAt(mac) ?: BATTERY_DEFAULT_PCT_PER_MIN) as Double
}
// Rates learned under the old flat model are not comparable with the
// startup-offset model: every sample was inflated by the fixed startup cost, and
// the stored average had drifted to about 3.4%/min against a true steady rate of
// about 2%/min. It would take many runs for the blend to forget that, so it's
// discarded once, the first time anything reads it. Done lazily here rather than
// in updated() because a package update doesn't call updated() until the app
// page is next saved.
private void migrateBatteryModel() {
if (state.batteryModel == 2) return
state.batteryDrainPerMin = [:]
state.batteryModel = 2
}
// What one specific room actually cost, from a run that cleaned only that room
// and ran to a genuine finish. Anything else would record a number that isn't
// the room's cost: a multi-room batch can't be split per room, a command-stopped
// or paused run was cut short, and a run that charged partway through has a
// battery delta that no longer covers the same stretch of cleaning.
private void recordRoomBatteryUse(String mac, Map run, Integer elapsedMin, def d, String newStatus, boolean interrupted) {
if (!run || (run.roomIds ?: []).size() != 1) return
if (interrupted || newStatus == "Paused" || newStatus == "Error") return
if (run.pausedElapsedMin) return
if (!elapsedMin || elapsedMin < 5) return
def startPct = state.cleaningStartBattery?.getAt(mac)
def endPct = toInt(d?.currentValue("battery"))
if (startPct == null || endPct == null) return
if ((startPct as Integer) < BATTERY_MEASURE_MIN_START_PCT) return
double used = (startPct as Integer) - endPct
if (used <= 0 || used > 100) return
def key = run.roomIds[0].toString()
state.roomBatteryUsed = state.roomBatteryUsed ?: [:]
def perRoom = state.roomBatteryUsed[mac] ?: [:]
def prev = perRoom[key] as Double
// Same exponential blend the room-time estimates use.
perRoom[key] = prev ? (prev * 0.7 + used * 0.3) : used
state.roomBatteryUsed[mac] = perRoom
ifDebug("recordRoomBatteryUse(${mac}): room ${key} used ${used}% this run -> ${String.format('%.0f', perRoom[key])}% average")
}
private void learnBatteryDrain(String mac, Integer elapsedMin, def d) {
migrateBatteryModel()
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
// The fixed startup cost comes off first: dividing the whole drop by the
// minutes made a 10-minute room look like 3.1%/min and a 37-minute one like
// 2.4%/min, so the "rate" depended on which kind of run happened last.
double running = used - BATTERY_STARTUP_PCT
if (running <= 0) return
double rate = running / (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
state.rotationSweepWaitingSince?.remove(mac) // a room is going out, so it's no longer waiting
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)
// Counted toward this trip's room limit, but only while a rotation sweep is
// actually running -- a one-off cleanRooms() isn't part of a trip.
if (completed && state.rotationSweepActive?.getAt(mac)) {
state.rotationSweepRooms = state.rotationSweepRooms ?: [:]
state.rotationSweepRooms[mac] = sweepRoomsDone(mac) + completed.size()
}
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}"
}