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