/** * Advanced Robot Vacuum Controller * Orchestration app for the Roborock Robot Vacuum driver with Live Dashboard, Master Override, and Dynamic 12-Room Setup. */ definition( name: "Advanced Robot Vacuum Controller", namespace: "ShaneAllen", author: "ShaneAllen", description: "Advanced orchestration, Dual-Vacuum support, ROI Analytics, Utilization Logic, and Adaptive Suction.", category: "Convenience", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Advanced Vacuum Controller", install: true, uninstall: true) { section() { input "appEnableSwitch", "capability.switch", title: "Master Application Kill Switch (Virtual Switch)", required: false, multiple: false, description: "ON = App Runs normally. OFF = App goes completely dormant.", submitOnChange: true if (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") { paragraph "
APPLICATION PAUSED: All automated routines, schedules, and event handlers are currently suspended via the virtual kill switch.
" } } List availableRooms = ["All"] for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"] && settings["roomName_${i}"]) { availableRooms << settings["roomName_${i}"] } } if (vacuum1 || vacuum2) { section("Live System Dashboard", hideable: true, hidden: false) { input name: "btnRefresh", type: "button", title: "🔄 Refresh Data & Re-Evaluate Power" paragraph buildDashboardHTML() } section("Instant Command Center (Clean Now)", hideable: true, hidden: false) { String cmdCenterHTML = """
AD-HOC DISPATCH TERMINAL
Select targets and parameters below to immediately override schedules and forcefully dispatch the fleet.
""" paragraph cmdCenterHTML input "quickCleanRooms", "enum", title: "Target Rooms", options: availableRooms, multiple: true, submitOnChange: true input "quickCleanSuction", "enum", title: "Suction Level", options: ["Quiet", "Balanced", "Turbo", "Max"], defaultValue: "Balanced" input "quickCleanWater", "enum", title: "Mop Water Level", options: ["Off", "Low", "Medium", "High"], defaultValue: "Medium" if (quickCleanRooms) { input name: "btnExecuteQuickClean", type: "button", title: "🚀 Execute Clean Now" } } section("Event History (Last 25 Events)", hideable: true, hidden: false) { paragraph buildHistoryHTML() } } section("1. Device Selection & Profiles", hideable: true, hidden: true) { input "vacuum1", "capability.actuator", title: "Select Vacuum 1 (Primary)", required: true, multiple: false, submitOnChange: true if (vacuum1) { input "vac1Brand", "enum", title: "↳ Vacuum 1 Brand Profile (Driver Abstraction)", options: ["Roborock (Community)", "iRobot Roomba (Native)", "Ecovacs/Deebot", "Dreame", "Generic/Switch"], defaultValue: "Roborock (Community)", submitOnChange: true input "vac1Model", "enum", title: "↳ Vacuum 1 Phantom Draw Model", options: ["Roborock S8 Series (3W Standby)", "Roborock QRevo Curve (4W Standby)", "Generic Roomba (7W Standby)", "Custom"], defaultValue: "Roborock QRevo Curve (4W Standby)", submitOnChange: true if (vac1Model == "Custom") input "vac1Watts", "decimal", title: "↳ Custom Standby Watts (V1)", defaultValue: 3.0 } input "vacuum2", "capability.actuator", title: "Select Vacuum 2 (Secondary)", required: false, multiple: false, submitOnChange: true if (vacuum2) { input "vac2Brand", "enum", title: "↳ Vacuum 2 Brand Profile (Driver Abstraction)", options: ["Roborock (Community)", "iRobot Roomba (Native)", "Ecovacs/Deebot", "Dreame", "Generic/Switch"], defaultValue: "Roborock (Community)", submitOnChange: true input "vac2Model", "enum", title: "↳ Vacuum 2 Phantom Draw Model", options: ["Roborock S8 Series (3W Standby)", "Roborock QRevo Curve (4W Standby)", "Generic Roomba (7W Standby)", "Custom"], defaultValue: "Roborock QRevo Curve (4W Standby)", submitOnChange: true if (vac2Model == "Custom") input "vac2Watts", "decimal", title: "↳ Custom Standby Watts (V2)", defaultValue: 4.0 } input "masterSwitch", "capability.switch", title: "Physical Master Suspend/Resume Switch", required: false, description: "If OFF, all automated routines are ignored and active cleans are paused." input "fullCleanSwitch", "capability.switch", title: "Physical Full House Clean Switch", required: false, description: "Trigger full house clean. Bypasses Time/Mode constraints." } section("2. Smart Room Configuration", hideable: true, hidden: true) { paragraph "Configure up to 12 rooms. Define parameters, sequencing, and environmental awareness." if (vacuum1) { input name: "btnSyncRooms", type: "button", title: "🔄 Auto-Sync Rooms from Vacuum 1" paragraph "Clicking Auto-Sync will pull the room map directly from Vacuum 1. It will automatically enable the slots, name the rooms, and insert the IDs below." paragraph "
" } if (vacuum2) { input name: "btnSyncRoomsV2", type: "button", title: "🔄 Auto-Sync Rooms from Vacuum 2" paragraph "Clicking this pulls the room map from Vacuum 2 into any available empty slots below, automatically assigning them to Vacuum 2." paragraph "
" } for (int i = 1; i <= 12; i++) { def rName = settings["roomName_${i}"] ?: "Room ${i}" def isHidden = settings["enableRoom_${i}"] ? false : true input "enableRoom_${i}", "bool", title: "Enable ${rName}", defaultValue: false, submitOnChange: true if (settings["enableRoom_${i}"]) { input "vacuumAssign_${i}", "enum", title: " ↳ Assign to Vacuum", options: ["Vacuum 1", "Vacuum 2"], defaultValue: "Vacuum 1", required: true input "roomName_${i}", "text", title: " ↳ Room Name (e.g., Kitchen)", required: true input "roomId_${i}", "text", title: " ↳ Room ID (from vacuum state)", required: false input "roomZone_${i}", "text", title: " ↳ Optional: Zone Coordinates (Overrides Room ID)", required: false input "roomWater_${i}", "enum", title: " ↳ Mop Water Level", options: ["Off", "Low", "Medium", "High"], defaultValue: "Medium" input "roomSuction_${i}", "enum", title: " ↳ Base Suction Power", options: ["Quiet", "Balanced", "Turbo", "Max"], defaultValue: "Balanced" input "roomSeq_${i}", "number", title: " ↳ Cleaning Sequence Order (1 = First)", defaultValue: i input "roomOccupancyThreshold_${i}", "number", title: " ↳ Min. Active Minutes to Require Cleaning", defaultValue: 15 input "roomHeavyTraffic_${i}", "number", title: " ↳ Active Mins for Adaptive Turbo Suction", defaultValue: 120 input "roomFan_${i}", "capability.switch", title: " ↳ Post-Clean Fan/Purifier", required: false input "roomFanTimer_${i}", "number", title: " ↳ Fan Run Time After Vacuum Leaves (Mins)", defaultValue: 15 input "roomHumidity_${i}", "capability.relativeHumidityMeasurement", title: " ↳ Micro-Climate Block (Humidity Sensor)", required: false input "roomHumidityThreshold_${i}", "number", title: " ↳ Block if Humidity > (%)", defaultValue: 75 input "roomMedia_${i}", "capability.musicPlayer", title: " ↳ Acoustic Adjust (Media Player)", required: false input "roomContact_${i}", "capability.contactSensor", title: " ↳ Perimeter Halt (Skip if Door Open)", required: false paragraph "Occupancy Arrays (Deduplicated Tracking):" input "roomMotion_${i}", "capability.motionSensor", title: " ↳ Motion Sensors (Accumulates Usage Time)", required: false, multiple: true input "roomBypass_${i}", "capability.motionSensor", title: " ↳ Real-Time Bypass Sensors (Skips Clean if Active)", required: false, multiple: true input "roomLight_${i}", "capability.switch", title: " ↳ Pre-Check Lighting (ON = Occupied)", required: false, multiple: true input "roomTV_${i}", "capability.switch", title: " ↳ Pre-Check TVs/Displays (ON = Occupied)", required: false, multiple: true input "roomSwitch_${i}", "capability.switch", title: " ↳ Individual Room Trigger Switch", required: false paragraph "
" } } } section("3. Automated Triggers & Modes", hideable: true, hidden: true) { paragraph "Good Night Logic & Tracking Suspension:" input "goodNightSwitch", "capability.switch", title: "Good Night Toggle Switch (Suspends Motion Tracking)", required: false input "goodNightMode", "mode", title: "Trigger Sweep & Suspend Tracking on 'Good Night' Mode", required: false, multiple: true input "goodNightRooms", "enum", title: "Rooms to clean during Good Night routine", options: availableRooms, required: false, multiple: true input "goodNightConflicts", "capability.switch", title: "Device Conflict Block", required: false, multiple: true, description: "Do not start Good Night sweep if these devices are actively ON." paragraph "Configurable Full House Clean Mode:" input "fullCleanMode", "mode", title: "Trigger Configured Full Clean on Mode", required: false, multiple: true input "fullCleanIgnoreSkip", "bool", title: "Ignore Occupancy Skip Logic (Force Clean All)", defaultValue: false input "fullCleanSuction", "enum", title: "Override Base Suction Power", options: ["Quiet", "Balanced", "Turbo", "Max"], required: false input "fullCleanWater", "enum", title: "Override Base Mop Water", options: ["Off", "Low", "Medium", "High"], required: false paragraph "Other Triggers:" input "schoolRunSwitch", "capability.switch", title: "School Drop-Off/Pickup Switch", required: false input "schoolRunRooms", "enum", title: "Rooms to clean during School Run", options: availableRooms, required: false, multiple: true } section("4. Global Constraints & Overdue Logic", hideable: true, hidden: true) { input "allowedModes", "mode", title: "Allowed Operating Modes (Leave blank for any)", required: false, multiple: true input "allowedStartTime", "time", title: "Quiet Hours: Do NOT run BEFORE", required: false input "allowedEndTime", "time", title: "Quiet Hours: Do NOT run AFTER", required: false input "maxIdleDays", "number", title: "Max days without a clean before forcing a Deep Clean", defaultValue: 3, required: false } section("5. Scheduled Mop-Only Routines", hideable: true, hidden: true) { input "mopDays", "enum", title: "Days to Run Mop-Only", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], required: false, multiple: true input "mopTime", "time", title: "Time to Run Mop-Only", required: false input "mopRooms", "enum", title: "Rooms to Mop", options: availableRooms, required: false, multiple: true } section("6. Hardware Protection, Efficiency & ROI", hideable: true, hidden: true) { input "pauseGracePeriod", "number", title: "Occupancy Grace Period (Minutes)", defaultValue: 2, required: true input "kwRate", "decimal", title: "Electricity Rate (\$ per kWh)", defaultValue: 0.15, required: true paragraph "Smart ROI Savings (Phantom Power Control):" input "smartROISavings", "bool", title: "Enable Smart ROI Power Management", defaultValue: true, submitOnChange: true paragraph "If enabled, the app automatically wakes the docks 90 seconds before dispatching, issues realignment commands, cuts power once charged to 100%, and wakes the dock for a top-off if the battery drops to 70%." input "dockPlug1", "capability.switch", title: "Vacuum 1 Dock Smart Plug", required: false input "dockPlug2", "capability.switch", title: "Vacuum 2 Dock Smart Plug", required: false input "wakeUpTime", "time", title: "Daily Time to Wake Docks (Prep for schedules)", required: false } section("7. Notifications & Maintenance Alerts", hideable: true, hidden: true) { input "notifyDevice", "capability.notification", title: "Send Push Notifications To", required: false, multiple: true input "notifyTypes", "enum", title: "Select Alert Types to Receive", options: ["Errors", "Bin Full", "Water Low", "Filter Dirty", "Clean Sensors", "Replace Bag"], multiple: true, required: false, defaultValue: ["Errors", "Bin Full", "Water Low", "Filter Dirty", "Clean Sensors", "Replace Bag"] input "alertThreshold", "number", title: "Alert when consumables drop below (%)", defaultValue: 5, required: true input "autoPauseOnError", "bool", title: "Auto-Pause Vacuum on Error", defaultValue: true } section("8. Logging & Maintenance", hideable: true, hidden: true) { input "logEnable", "bool", title: "Enable Informational Logging", defaultValue: true input "clearHistory", "bool", title: "Clear Dashboard History", defaultValue: false input "clearOccupancy", "bool", title: "Reset All Room Occupancy Counters", defaultValue: false input "clearROI", "bool", title: "Reset Financial Savings Counter", defaultValue: false } } } def installed() { logInfo "Installed with settings: ${settings}" state.history = [] state.lastCleanTime = now() initialize() } def updated() { logInfo "Updated with settings: ${settings}" if (!state.history) state.history = [] if (!state.lastCleanTime) state.lastCleanTime = now() if (!state.lastDispatchLog) state.lastDispatchLog = [:] if (settings.clearHistory) { state.history = [] app.updateSetting("clearHistory", [value: "false", type: "bool"]) } if (settings.clearOccupancy) { for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"]) { String rName = settings["roomName_${i}"] state["occSecs_${rName}"] = 0 state["motionEvents_${rName}"] = 0 state["isMotionActive_${rName}"] = false } } state.lastDispatchLog = [:] app.updateSetting("clearOccupancy", [value: "false", type: "bool"]) } if (settings.clearROI) { state.dock1OfflineHours = 0.0 state.dock2OfflineHours = 0.0 app.updateSetting("clearROI", [value: "false", type: "bool"]) } initialize() evaluateROIPowerState() } boolean isAppPaused() { return (appEnableSwitch && appEnableSwitch.currentValue("switch") == "off") } def initialize() { unsubscribe() unschedule() if (appEnableSwitch) subscribe(appEnableSwitch, "switch", appEnableHandler) if (isAppPaused()) { logInfo "App Initialized in PAUSED state via Master Virtual Switch." return } addToHistory("App Initialized and Subscriptions Updated.") state.v1_intentAction = "Idle" state.v1_intentRooms = "--" state.v1_maskedRooms = [] state.v2_intentAction = "Idle" state.v2_intentRooms = "--" state.v2_maskedRooms = [] if (wakeUpTime) schedule(wakeUpTime, "wakeDocks") if (mopTime) schedule(mopTime, "mopRoutineHandler") if (maxIdleDays) runEvery1Hour("overdueCheckHandler") if (masterSwitch) subscribe(masterSwitch, "switch", masterSwitchHandler) if (fullCleanSwitch) subscribe(fullCleanSwitch, "switch.on", fullCleanHandler) if (schoolRunSwitch) subscribe(schoolRunSwitch, "switch.on", schoolRunHandler) subscribe(location, "mode", modeHandler) if (dockPlug1) { subscribe(dockPlug1, "switch", dockPlugHandler) if (dockPlug1.currentValue("switch") == "off" && !state.dock1OffTime) state.dock1OffTime = now() } if (dockPlug2) { subscribe(dockPlug2, "switch", dockPlugHandler) if (dockPlug2.currentValue("switch") == "off" && !state.dock2OffTime) state.dock2OffTime = now() } for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"]) { String rName = settings["roomName_${i}"] if (state["occSecs_${rName}"] == null) state["occSecs_${rName}"] = 0 if (state["motionEvents_${rName}"] == null) state["motionEvents_${rName}"] = 0 if (state["isMotionActive_${rName}"] == null) state["isMotionActive_${rName}"] = false if (settings["roomSwitch_${i}"]) subscribe(settings["roomSwitch_${i}"], "switch.on", roomSwitchHandler) if (settings["roomMotion_${i}"]) subscribe(settings["roomMotion_${i}"], "motion", occupancyHandler) if (settings["roomBypass_${i}"]) subscribe(settings["roomBypass_${i}"], "motion", occupancyHandler) if (settings["roomLight_${i}"]) subscribe(settings["roomLight_${i}"], "switch", occupancyHandler) if (settings["roomTV_${i}"]) subscribe(settings["roomTV_${i}"], "switch", occupancyHandler) } } if (vacuum1) { subscribe(vacuum1, "state", vacuumStateHandler) subscribe(vacuum1, "battery", batteryHandler) subscribe(vacuum1, "error", errorHandler) subscribe(vacuum1, "dockError", dockErrorHandler) subscribe(vacuum1, "remainingFilter", consumableHandler) subscribe(vacuum1, "remainingMainBrush", consumableHandler) subscribe(vacuum1, "remainingSideBrush", consumableHandler) subscribe(vacuum1, "remainingSensors", consumableHandler) } if (vacuum2) { subscribe(vacuum2, "state", vacuumStateHandler) subscribe(vacuum2, "battery", batteryHandler) subscribe(vacuum2, "error", errorHandler) subscribe(vacuum2, "dockError", dockErrorHandler) subscribe(vacuum2, "remainingFilter", consumableHandler) subscribe(vacuum2, "remainingMainBrush", consumableHandler) subscribe(vacuum2, "remainingSideBrush", consumableHandler) subscribe(vacuum2, "remainingSensors", consumableHandler) } } // ========================================== // VIRTUAL KILL SWITCH HANDLER // ========================================== def appEnableHandler(evt) { if (evt.value == "off") { addToHistory("SYSTEM PAUSED via Master Virtual Switch") initialize() } else { addToHistory("SYSTEM RESUMED via Master Virtual Switch") initialize() } } // ========================================== // HARDWARE ABSTRACTION LAYER (HAL) // ========================================== void commandVacuum(vacDevice, String brand, String cmd) { if (!vacDevice) return try { switch(brand) { case "Roborock (Community)": if (cmd == "pause") vacDevice.appPause() if (cmd == "resume") vacDevice.appRoomResume() if (cmd == "dock") { try { vacDevice.charge() } catch(e) { try { vacDevice.home() } catch(ex){} } } break case "iRobot Roomba (Native)": case "Ecovacs/Deebot": case "Dreame": if (cmd == "pause") vacDevice.pause() if (cmd == "resume") vacDevice.resume() if (cmd == "dock") { try { vacDevice.charge() } catch(e) { try { vacDevice.home() } catch(ex){} } } break default: if (cmd == "pause") vacDevice.off() if (cmd == "resume") vacDevice.on() break } } catch (e) { logInfo("HAL Error sending ${cmd} to ${brand}: ${e}") } } void dispatchVacuum(vacDevice, String brand, String type, String target, String water, String suction) { if (!vacDevice) return try { switch(brand) { case "Roborock (Community)": if (suction) { try { vacDevice.setFanSpeed(suction.toLowerCase()) } catch(e) { } try { vacDevice.setFanPower(suction.toLowerCase()) } catch(e) { } } if (water) { try { vacDevice.setWater(water.toLowerCase()) } catch(e) { } try { vacDevice.setWaterLevel(water.toLowerCase()) } catch(e) { } } if (type == "room") vacDevice.appRoomClean(target) else if (type == "zone") vacDevice.appZoneClean(target) break case "Dreame": if (type == "room") vacDevice.appRoomClean(target, water, suction) break case "iRobot Roomba (Native)": case "Ecovacs/Deebot": if (type == "room") vacDevice.cleanRoom(target) break default: vacDevice.on() break } } catch (e) { logInfo("HAL Error dispatching ${type} to ${brand}: ${e}") } } // ========================================== // GATEKEEPER LOGIC (TIME & MODE) // ========================================== boolean canRunAutomated() { if (isAppPaused()) return false if (masterSwitch && masterSwitch.currentValue("switch") == "off") return false if (allowedModes && !allowedModes.contains(location.mode)) return false if (allowedStartTime && allowedEndTime) { Date start = timeToday(allowedStartTime, location.timeZone) Date end = timeToday(allowedEndTime, location.timeZone) Date now = new Date() if (start.before(end)) { if (!(now.after(start) && now.before(end))) return false } else { if (!(now.after(start) || now.before(end))) return false } } return true } boolean isRoomCurrentlyOccupied(int roomIndex) { def motions = [settings["roomMotion_${roomIndex}"]].flatten().findAll { it } def bypasses = [settings["roomBypass_${roomIndex}"]].flatten().findAll { it } def lights = [settings["roomLight_${roomIndex}"]].flatten().findAll { it } def tvs = [settings["roomTV_${roomIndex}"]].flatten().findAll { it } if (motions.any { it.currentValue("motion") == "active" }) return true if (bypasses.any { it.currentValue("motion") == "active" }) return true if (lights.any { it.currentValue("switch") == "on" }) return true if (tvs.any { it.currentValue("switch") == "on" }) return true return false } // ========================================== // NOTIFICATION ROUTER // ========================================== void sendAlert(String alertType, String msg) { if (settings.notifyTypes?.contains(alertType)) { if (notifyDevice) notifyDevice.deviceNotification(msg) addToHistory("Push Sent (${alertType}): ${msg}") } } // ========================================== // EVENT HANDLERS & SMART DISPATCH WRAPPER // ========================================== void requestDispatch(List selectedRoomNames, Map options = [:]) { if (isAppPaused()) return boolean needsWake = false if (settings.smartROISavings) { if (dockPlug1 && dockPlug1.currentValue("switch") == "off") needsWake = true if (dockPlug2 && dockPlug2.currentValue("switch") == "off") needsWake = true } if (needsWake) { wakeDocks() addToHistory("Smart ROI Active: Waking docks. Dispatch delayed by 90 seconds.") state.pendingDispatchRooms = selectedRoomNames state.pendingDispatchOptions = options runIn(90, "executePendingDispatch", [overwrite: true]) } else { executeRoomClean(selectedRoomNames, options) } } def executePendingDispatch() { if (isAppPaused()) return List rooms = state.pendingDispatchRooms ?: [] Map opts = state.pendingDispatchOptions ?: [:] if (rooms) { executeRoomClean(rooms, opts) } state.pendingDispatchRooms = [] state.pendingDispatchOptions = [:] } void evaluateROIPowerState() { if (isAppPaused() || !settings.smartROISavings) return if (state.pendingDispatchRooms?.size() > 0) return // Do not cut power if we are waiting for a dispatch if (vacuum1 && dockPlug1) { String state1 = vacuum1.currentValue("state")?.toString()?.toLowerCase() ?: "" String bat1Str = vacuum1.currentValue("battery")?.toString() int bat1 = bat1Str?.isInteger() ? (bat1Str as Integer) : 0 if (bat1 == 100 && state1 in ["charging", "charged", "docked", "idle", "full"]) { if (dockPlug1.currentValue("switch") != "off") { dockPlug1.off() addToHistory("V1 Dock Powered OFF (Smart ROI Assassin)") } } else if (bat1 <= 70) { if (dockPlug1.currentValue("switch") == "off") { dockPlug1.on() addToHistory("V1 Dock Powered ON (Smart ROI Top-Off: Battery at ${bat1}%)") runIn(15, "remountVacuum1") } } } if (vacuum2 && dockPlug2) { String state2 = vacuum2.currentValue("state")?.toString()?.toLowerCase() ?: "" String bat2Str = vacuum2.currentValue("battery")?.toString() int bat2 = bat2Str?.isInteger() ? (bat2Str as Integer) : 0 if (bat2 == 100 && state2 in ["charging", "charged", "docked", "idle", "full"]) { if (dockPlug2.currentValue("switch") != "off") { dockPlug2.off() addToHistory("V2 Dock Powered OFF (Smart ROI Assassin)") } } else if (bat2 <= 70) { if (dockPlug2.currentValue("switch") == "off") { dockPlug2.on() addToHistory("V2 Dock Powered ON (Smart ROI Top-Off: Battery at ${bat2}%)") runIn(15, "remountVacuum2") } } } } def remountVacuum1() { if (vacuum1 && dockPlug1 && dockPlug1.currentValue("switch") == "on") { commandVacuum(vacuum1, settings.vac1Brand, "dock") addToHistory("V1 Alignment: Securing charging pins.") } } def remountVacuum2() { if (vacuum2 && dockPlug2 && dockPlug2.currentValue("switch") == "on") { commandVacuum(vacuum2, settings.vac2Brand, "dock") addToHistory("V2 Alignment: Securing charging pins.") } } def remountVacuums() { remountVacuum1() remountVacuum2() } def appButtonHandler(btn) { if (isAppPaused()) { logInfo "Command Center ignored: Master Virtual Switch is OFF." return } if (btn == "btnRefresh") { logInfo "Dashboard data manually refreshed." if (vacuum1 && vacuum1.hasCommand("refresh")) { try { vacuum1.refresh() } catch (e) { log.warn "Vacuum 1 refresh failed: ${e}" } } if (vacuum2 && vacuum2.hasCommand("refresh")) { try { vacuum2.refresh() } catch (e) { log.warn "Vacuum 2 refresh failed: ${e}" } } evaluateROIPowerState() addToHistory("Data Refresh: Triggered successfully.") } if (btn == "btnSyncRooms") { if (vacuum1) { String roomsAttr = vacuum1.currentValue("rooms")?.toString() ?: vacuum1.currentValue("Rooms")?.toString() if (roomsAttr) { try { def roomMap = new groovy.json.JsonSlurper().parseText(roomsAttr) int i = 1 roomMap.each { id, name -> if (i <= 12) { app.updateSetting("enableRoom_${i}", [type: "bool", value: true]) app.updateSetting("vacuumAssign_${i}", [type: "enum", value: "Vacuum 1"]) app.updateSetting("roomName_${i}", [type: "text", value: name]) app.updateSetting("roomId_${i}", [type: "text", value: id]) i++ } } addToHistory("Room Sync: Successfully imported ${i-1} rooms from Vacuum 1.") } catch (e) { addToHistory("Room Sync Failed: Could not parse vacuum room data.") log.error "Room Sync Error: ${e}" } } else { addToHistory("Room Sync Failed: No room data found on Vacuum 1.") } } } if (btn == "btnSyncRoomsV2") { if (vacuum2) { String roomsAttr = vacuum2.currentValue("rooms")?.toString() ?: vacuum2.currentValue("Rooms")?.toString() if (roomsAttr) { try { def roomMap = new groovy.json.JsonSlurper().parseText(roomsAttr) int added = 0 roomMap.each { id, name -> int targetSlot = 0 for (int k = 1; k <= 12; k++) { if (!settings["enableRoom_${k}"]) { targetSlot = k break } } if (targetSlot > 0) { app.updateSetting("enableRoom_${targetSlot}", [type: "bool", value: true]) app.updateSetting("vacuumAssign_${targetSlot}", [type: "enum", value: "Vacuum 2"]) app.updateSetting("roomName_${targetSlot}", [type: "text", value: name]) app.updateSetting("roomId_${targetSlot}", [type: "text", value: id]) added++ } } addToHistory("Room Sync: Successfully imported ${added} rooms from Vacuum 2 into empty slots.") } catch (e) { addToHistory("Room Sync Failed: Could not parse vacuum room data for Vacuum 2.") log.error "Room Sync Error V2: ${e}" } } else { addToHistory("Room Sync Failed: No room data found on Vacuum 2.") } } } if (btn == "btnExecuteQuickClean") { if (settings.quickCleanRooms) { List roomsToClean = [] + settings.quickCleanRooms requestDispatch(roomsToClean, [ ignoreSkip: true, suction: settings.quickCleanSuction, water: settings.quickCleanWater ]) app.updateSetting("quickCleanRooms", [type: "enum", value: []]) } } } def mopRoutineHandler() { if (!canRunAutomated()) return if (mopDays) { def df = new java.text.SimpleDateFormat("EEEE") df.setTimeZone(location.timeZone) String day = df.format(new Date()) if (!mopDays.contains(day)) return } if (mopRooms) { addToHistory("Triggered: Scheduled Mop-Only Routine") requestDispatch(mopRooms, [ignoreSkip: false, suction: "Off", water: "High"]) } } def overdueCheckHandler() { if (isAppPaused()) return if (!maxIdleDays || !state.lastCleanTime) return long daysIdle = (now() - state.lastCleanTime) / 86400000 if (daysIdle >= maxIdleDays) { if (canRunAutomated()) { addToHistory("Overdue Catcher: ${daysIdle} days idle. Forcing Deep Clean!") requestDispatch(["All"], [ignoreSkip: true, suction: "Max", water: "High"]) } } } def consumableHandler(evt) { if (isAppPaused()) return String part = evt.name?.toLowerCase() int value = (evt.value ?: "100") as Integer int threshold = settings.alertThreshold ?: 5 if (value <= threshold) { String msg = "${evt.device.displayName} Maintenance: ${part.capitalize()} life is at ${value}%." if (part.contains("filter")) sendAlert("Filter Dirty", msg) else if (part.contains("sensor")) sendAlert("Clean Sensors", msg) else if (part.contains("bag") || part.contains("dust")) sendAlert("Replace Bag", msg) } } def dockErrorHandler(evt) { if (isAppPaused()) return String err = evt.value?.toString()?.toLowerCase() if (err == "no error" || err == "ok" || err == "0") return if (err.contains("water empty") || err.contains("water low")) { sendAlert("Water Low", "${evt.device.displayName} Dock: Water Empty. Refill required.") } else if (err.contains("dust") || err.contains("bag")) { sendAlert("Replace Bag", "${evt.device.displayName} Dock: ${evt.value.capitalize()}") } else { sendAlert("Errors", "${evt.device.displayName} Dock Alert: ${evt.value.capitalize()}") } addToHistory("Dock Error: ${evt.value}") } def batteryHandler(evt) { evaluateROIPowerState() } def dockPlugHandler(evt) { if (isAppPaused()) return // Safely grab the device ID regardless of driver quirks String eId = evt.device?.id?.toString() ?: evt.deviceId?.toString() if (evt.value == "off") { if (eId == dockPlug1?.id && !state.dock1OffTime) state.dock1OffTime = now() if (eId == dockPlug2?.id && !state.dock2OffTime) state.dock2OffTime = now() } else if (evt.value == "on") { if (eId == dockPlug1?.id && state.dock1OffTime) { state.dock1OfflineHours = (state.dock1OfflineHours ?: 0.0) + ((now() - state.dock1OffTime) / 3600000.0) state.dock1OffTime = null } if (eId == dockPlug2?.id && state.dock2OffTime) { state.dock2OfflineHours = (state.dock2OfflineHours ?: 0.0) + ((now() - state.dock2OffTime) / 3600000.0) state.dock2OffTime = null } } } def wakeDocks() { if (isAppPaused()) return boolean waked = false if (dockPlug1 && dockPlug1.currentValue("switch") != "on") { dockPlug1.on(); waked = true; } if (dockPlug2 && dockPlug2.currentValue("switch") != "on") { dockPlug2.on(); waked = true; } if (waked) { addToHistory("Docks Powered ON (Smart ROI Wake-Up)") runIn(15, "remountVacuums") // Allow plugs to power up, then command alignment } } def vacuumStateHandler(evt) { if (isAppPaused()) return String vState = evt.value?.toString()?.toLowerCase() if (vState in ["charging", "charged", "docked", "returning to dock", "idle", "full"]) { if (evt.device.id == vacuum1?.id && state.v1_maskedRooms?.size() > 0) { state.v1_maskedRooms.each { rName -> triggerFanCountdown(rName) } state.v1_maskedRooms = [] state.v1_intentAction = "Idle" state.v1_intentRooms = "--" } if (evt.device.id == vacuum2?.id && state.v2_maskedRooms?.size() > 0) { state.v2_maskedRooms.each { rName -> triggerFanCountdown(rName) } state.v2_maskedRooms = [] state.v2_intentAction = "Idle" state.v2_intentRooms = "--" } } evaluateROIPowerState() } def triggerFanCountdown(String roomName) { if (isAppPaused()) return for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"] && settings["roomName_${i}"] == roomName && settings["roomFan_${i}"]) { int delayMins = settings["roomFanTimer_${i}"] ?: 15 runIn(delayMins * 60, "turnOffFan", [data: [roomId: i], overwrite: true]) addToHistory("${roomName} Dust Settler: Fan off in ${delayMins}m.") } } } def turnOffFan(data) { if (isAppPaused()) return def fan = settings["roomFan_${data.roomId}"] if (fan) { fan.off() addToHistory("${settings["roomName_${data.roomId}"]} Dust Settler: Complete (Fan OFF)") } } def masterSwitchHandler(evt) { if (isAppPaused()) return if (evt.value == "off") { addToHistory("Master Switch: OFF (Suspended)") if (vacuum1 && vacuum1.currentValue("state")?.toLowerCase() in ["cleaning", "room clean", "zone clean"]) commandVacuum(vacuum1, settings.vac1Brand, "pause") if (vacuum2 && vacuum2.currentValue("state")?.toLowerCase() in ["cleaning", "room clean", "zone clean"]) commandVacuum(vacuum2, settings.vac2Brand, "pause") unschedule("resumeVacuum1") unschedule("resumeVacuum2") unschedule("executePendingDispatch") } else { addToHistory("Master Switch: ON (Resumed)") } } def occupancyHandler(evt) { if (isAppPaused()) return if (masterSwitch && masterSwitch.currentValue("switch") == "off") return String triggeredRoom = "" int triggeredIndex = 0 boolean isAccumulator = false String evtDevId = evt.device.id?.toString() ?: evt.deviceId?.toString() for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"]) { def motions = [settings["roomMotion_${i}"]].flatten().findAll { it } def bypasses = [settings["roomBypass_${i}"]].flatten().findAll { it } def lights = [settings["roomLight_${i}"]].flatten().findAll { it } def tvs = [settings["roomTV_${i}"]].flatten().findAll { it } if (motions.any { it.id?.toString() == evtDevId }) { triggeredRoom = settings["roomName_${i}"] triggeredIndex = i isAccumulator = true } if (bypasses.any { it.id?.toString() == evtDevId } || lights.any { it.id?.toString() == evtDevId } || tvs.any { it.id?.toString() == evtDevId }) { triggeredRoom = settings["roomName_${i}"] triggeredIndex = i } if (triggeredRoom) break } } if (!triggeredRoom) return boolean isGoodNight = false if (goodNightMode && location.mode in goodNightMode) isGoodNight = true if (goodNightSwitch && goodNightSwitch.currentValue("switch") == "on") isGoodNight = true boolean v1InRoom = state.v1_maskedRooms?.contains(triggeredRoom) boolean v2InRoom = state.v2_maskedRooms?.contains(triggeredRoom) if (evt.name == "motion" && isAccumulator && !v1InRoom && !v2InRoom) { if (!isGoodNight) { def motions = [settings["roomMotion_${triggeredIndex}"]].flatten().findAll { it } int activeSensors = 0 motions.each { m -> if (m.id?.toString() == evtDevId) { if (evt.value == "active") activeSensors++ } else { if (m.currentValue("motion") == "active") activeSensors++ } } boolean wasActive = state["isMotionActive_${triggeredRoom}"] ?: false if (evt.value == "active") { state["motionEvents_${triggeredRoom}"] = (state["motionEvents_${triggeredRoom}"] ?: 0) + 1 if (!wasActive) { state["motionStart_${triggeredRoom}"] = now() state["isMotionActive_${triggeredRoom}"] = true } } else if (evt.value == "inactive") { if (activeSensors == 0 && wasActive) { state["isMotionActive_${triggeredRoom}"] = false long start = state["motionStart_${triggeredRoom}"] ?: now() long deltaSecs = (now() - start) / 1000 state["occSecs_${triggeredRoom}"] = (state["occSecs_${triggeredRoom}"] ?: 0) + deltaSecs addToHistory("Activity Log: ${triggeredRoom} recorded ${deltaSecs}s of motion.") } } } } if (evt.value == "active" || evt.value == "on") { if (v1InRoom && vacuum1 && vacuum1.currentValue("state")?.toLowerCase() in ["cleaning", "room clean", "zone clean"]) { addToHistory("V1 Paused: Intrusion in ${triggeredRoom}") commandVacuum(vacuum1, settings.vac1Brand, "pause") unschedule("resumeVacuum1") } if (v2InRoom && vacuum2 && vacuum2.currentValue("state")?.toLowerCase() in ["cleaning", "room clean", "zone clean"]) { addToHistory("V2 Paused: Intrusion in ${triggeredRoom}") commandVacuum(vacuum2, settings.vac2Brand, "pause") unschedule("resumeVacuum2") } } else if (evt.value == "inactive" || evt.value == "off") { if (!isRoomCurrentlyOccupied(triggeredIndex)) { int delaySeconds = (settings.pauseGracePeriod ?: 2) * 60 if (v1InRoom && vacuum1 && vacuum1.currentValue("state")?.toLowerCase() == "paused") { addToHistory("V1 Timer: ${triggeredRoom} clear. Resuming in ${settings.pauseGracePeriod}m.") runIn(delaySeconds, "resumeVacuum1", [overwrite: true]) } if (v2InRoom && vacuum2 && vacuum2.currentValue("state")?.toLowerCase() == "paused") { addToHistory("V2 Timer: ${triggeredRoom} clear. Resuming in ${settings.pauseGracePeriod}m.") runIn(delaySeconds, "resumeVacuum2", [overwrite: true]) } } } } def resumeVacuum1() { if (isAppPaused()) return if (masterSwitch && masterSwitch.currentValue("switch") == "off") return if (vacuum1 && vacuum1.currentValue("state")?.toLowerCase() == "paused") { addToHistory("V1 Resumed: Grace Period Complete") commandVacuum(vacuum1, settings.vac1Brand, "resume") } } def resumeVacuum2() { if (isAppPaused()) return if (masterSwitch && masterSwitch.currentValue("switch") == "off") return if (vacuum2 && vacuum2.currentValue("state")?.toLowerCase() == "paused") { addToHistory("V2 Resumed: Grace Period Complete") commandVacuum(vacuum2, settings.vac2Brand, "resume") } } def modeHandler(evt) { if (isAppPaused()) return if (goodNightMode && evt.value in goodNightMode) { if (!canRunAutomated()) { addToHistory("Good Night Blocked: Global Constraints Active") return } if (goodNightConflicts) { def activeConflicts = goodNightConflicts.findAll { it.currentValue("switch") == "on" } if (activeConflicts.size() > 0) { def conflictNames = activeConflicts.collect { it.displayName }.join(", ") addToHistory("Good Night Blocked: Active Devices (${conflictNames})") return } } addToHistory("Triggered: Good Night Sweep") requestDispatch(goodNightRooms, [ignoreSkip: false]) } if (fullCleanMode && evt.value in fullCleanMode) { if (!canRunAutomated()) return addToHistory("Triggered: Configurable Full Clean Mode") requestDispatch(["All"], [ ignoreSkip: settings.fullCleanIgnoreSkip, suction: settings.fullCleanSuction, water: settings.fullCleanWater ]) } } def fullCleanHandler(evt) { if (isAppPaused()) return if (masterSwitch && masterSwitch.currentValue("switch") == "off") return addToHistory("Triggered: Full Clean Switch (Manual Override)") requestDispatch(["All"], [ignoreSkip: true]) } def roomSwitchHandler(evt) { if (isAppPaused()) return if (masterSwitch && masterSwitch.currentValue("switch") == "off") return for (int i = 1; i <= 12; i++) { def sw = settings["roomSwitch_${i}"] if (settings["enableRoom_${i}"] && sw && sw.id == evt.deviceId) { requestDispatch([settings["roomName_${i}"]], [ignoreSkip: true]) break } } } def schoolRunHandler(evt) { if (isAppPaused()) return if (!canRunAutomated()) { addToHistory("School Run Blocked: Global Constraints Active") return } addToHistory("Triggered: School Run Routine") requestDispatch(schoolRunRooms, [ignoreSkip: false]) } def errorHandler(evt) { if (isAppPaused()) return String errorMsg = evt.value?.toString()?.toLowerCase() if (errorMsg == "no error" || errorMsg == "0") return String msg = "${evt.device.displayName} Error: ${evt.value}" addToHistory("${msg}") sendAlert("Errors", msg) if (autoPauseOnError) { def brand = (evt.device.id == vacuum1?.id) ? settings.vac1Brand : settings.vac2Brand commandVacuum(evt.device, brand, "pause") } } // ========================================== // CORE LOGIC: PRE-EVALUATION & DISPATCH // ========================================== void executeRoomClean(List selectedRoomNames, Map options = [:]) { if (isAppPaused()) return boolean ignoreSkip = options.ignoreSkip ?: false String overrideSuction = options.suction String overrideWater = options.water if (!selectedRoomNames) return if (selectedRoomNames.contains("All")) { List allActive = [] for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"] && settings["roomName_${i}"]) allActive << settings["roomName_${i}"] } selectedRoomNames = allActive } def v1Queue = [] def v2Queue = [] def dispatchLog = [:] for (String targetName : selectedRoomNames) { for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"] && settings["roomName_${i}"] == targetName) { if (settings["roomContact_${i}"] && settings["roomContact_${i}"].currentValue("contact") == "open") { dispatchLog[targetName] = "Skipped (Perimeter Open)" continue } if (settings["roomHumidity_${i}"]) { def currentHum = settings["roomHumidity_${i}"].currentValue("humidity") ?: 0 def limitHum = settings["roomHumidityThreshold_${i}"] ?: 75 if (currentHum > limitHum) { dispatchLog[targetName] = "Skipped (Humidity: ${currentHum}%)" continue } } int occSecs = state["occSecs_${targetName}"] ?: 0 int occMins = (occSecs / 60) as Integer int minThreshold = settings["roomOccupancyThreshold_${i}"] ?: 15 int heavyThreshold = settings["roomHeavyTraffic_${i}"] ?: 120 if (!ignoreSkip && occMins < minThreshold) { dispatchLog[targetName] = "Skipped (Clean: ${occMins}/${minThreshold}m)" continue } if (isRoomCurrentlyOccupied(i)) { dispatchLog[targetName] = "Skipped (Currently Occupied)" continue } String finalSuction = overrideSuction ?: settings["roomSuction_${i}"] ?: "Balanced" if (settings["roomMedia_${i}"]) { def pState = settings["roomMedia_${i}"].currentValue("status") ?: settings["roomMedia_${i}"].currentValue("state") if (pState?.toString()?.toLowerCase() == "playing") { finalSuction = "Quiet" addToHistory("Acoustic Override: ${targetName} set to Quiet (Media Playing)") } } else if (!overrideSuction && occMins >= heavyThreshold) { finalSuction = "Turbo" addToHistory("Adaptive Suction: ${targetName} set to Turbo (${occMins}m traffic)") } dispatchLog[targetName] = "Cleaned (${finalSuction})" def roomData = [ name: targetName, id: settings["roomId_${i}"], zone: settings["roomZone_${i}"], water: overrideWater ?: settings["roomWater_${i}"] ?: "Medium", suction: finalSuction, seq: settings["roomSeq_${i}"] ?: 99, index: i ] if (settings["vacuumAssign_${i}"] == "Vacuum 2") v2Queue << roomData else v1Queue << roomData } } } state.lastDispatchLog = dispatchLog if (v1Queue.size() > 0 || v2Queue.size() > 0) { state.lastCleanTime = now() } if (v1Queue.size() > 0 && vacuum1) { v1Queue.sort { it.seq } state.v1_maskedRooms = v1Queue.collect { it.name } state.v1_intentAction = "Dispatched (Sequence)" state.v1_intentRooms = state.v1_maskedRooms.join(", ") v1Queue.each { room -> if (settings["roomFan_${room.index}"]) { settings["roomFan_${room.index}"].on() addToHistory("${room.name} Dust Settler: Fan ON") } if (room.zone) dispatchVacuum(vacuum1, settings.vac1Brand, "zone", room.zone, "", "") else if (room.id) dispatchVacuum(vacuum1, settings.vac1Brand, "room", room.id, room.water, room.suction) state["occSecs_${room.name}"] = 0 state["motionEvents_${room.name}"] = 0 state["isMotionActive_${room.name}"] = false pauseExecution(2500) } } if (v2Queue.size() > 0 && vacuum2) { v2Queue.sort { it.seq } state.v2_maskedRooms = v2Queue.collect { it.name } state.v2_intentAction = "Dispatched (Sequence)" state.v2_intentRooms = state.v2_maskedRooms.join(", ") v2Queue.each { room -> if (settings["roomFan_${room.index}"]) { settings["roomFan_${room.index}"].on() addToHistory("${room.name} Dust Settler: Fan ON") } if (room.zone) dispatchVacuum(vacuum2, settings.vac2Brand, "zone", room.zone, "", "") else if (room.id) dispatchVacuum(vacuum2, settings.vac2Brand, "room", room.id, room.water, room.suction) state["occSecs_${room.name}"] = 0 state["motionEvents_${room.name}"] = 0 state["isMotionActive_${room.name}"] = false pauseExecution(2500) } } } void logInfo(String msg) { if (settings?.logEnable) log.info "${app.name}: ${msg}" } // ========================================== // HTML DASHBOARD GENERATION // ========================================== void addToHistory(String event) { if (!state.history) state.history = [] String timeStamp = new Date().format("MM/dd/yy hh:mm:ss a", location.timeZone) state.history.add(0, "[${timeStamp}] ${event}") if (state.history.size() > 25) state.history = state.history[0..24] } String buildHistoryHTML() { if (!state.history || state.history.size() == 0) return "
No events logged yet.
" String html = "
" state.history.each { item -> html += "
${item}
" } html += "
" return html } double getVacWatts(String modelStr, Double customWatts) { if (modelStr?.contains("S8")) return 3.0 if (modelStr?.contains("QRevo")) return 4.0 if (modelStr?.contains("Roomba")) return 7.0 return customWatts ?: 3.0 } String getConsumableVal(vacDevice, String attr) { def val = vacDevice.currentValue(attr) return val != null ? "${val}%" : "--" } String generateVacuumTable(vacDevice, String vacTitle, String intentAction, String intentRooms) { if (!vacDevice) return "" String vState = vacDevice.currentValue("state") ?: "Unknown" String vBat = vacDevice.currentValue("battery") ?: "--" String vErr = vacDevice.currentValue("error") ?: "No error" String dockErr = vacDevice.currentValue("dockError") ?: "No error" String cleanArea = vacDevice.currentValue("cleanArea") ?: "--" String cleanTime = vacDevice.currentValue("cleanTime") ?: "--" String stateColor = "black" if (vState.toLowerCase() in ["cleaning", "room clean", "zone clean"]) stateColor = "green" if (vState.toLowerCase() in ["charging", "charged"]) stateColor = "blue" if (vState.toLowerCase() in ["paused", "returning to dock"]) stateColor = "orange" String filter = getConsumableVal(vacDevice, "remainingFilter") ?: getConsumableVal(vacDevice, "filter") String mBrush = getConsumableVal(vacDevice, "remainingMainBrush") ?: getConsumableVal(vacDevice, "mainBrush") String sBrush = getConsumableVal(vacDevice, "remainingSideBrush") ?: getConsumableVal(vacDevice, "sideBrush") String sensor = getConsumableVal(vacDevice, "remainingSensors") ?: getConsumableVal(vacDevice, "sensor") String html = "
" html += "" html += "" html += "" html += "" html += "" html += "" html += "" html += "" html += "" html += "" html += "" html += "" html += "" String errColor = (vErr.toLowerCase() in ['no error', 'ok', '0']) ? "green" : "red" String dockErrColor = (dockErr.toLowerCase() in ['no error', 'ok', '0']) ? "green" : "red" html += "" html += "" html += "" html += "" html += "" html += "" html += "
${vacTitle}
State${vState.capitalize()}Battery${vBat}%
App Action${intentAction}App Target${intentRooms}
Last Clean Area${cleanArea} m²Last Clean Time${cleanTime} mins
Hardware Error${vErr.capitalize()}Dock Status${dockErr.capitalize()}
FilterMain BrushSide BrushSensors
${filter}${mBrush}${sBrush}${sensor}
" return html } String buildDashboardHTML() { String html = "" if (isAppPaused()) { html += "
SYSTEM PAUSED VIA MASTER VIRTUAL SWITCH
" } if (vacuum1) html += generateVacuumTable(vacuum1, "Vacuum 1 (Primary)", state.v1_intentAction ?: "Idle", state.v1_intentRooms ?: "--") if (vacuum2) html += generateVacuumTable(vacuum2, "Vacuum 2 (Secondary)", state.v2_intentAction ?: "Idle", state.v2_intentRooms ?: "--") boolean isPaused = masterSwitch ? (masterSwitch.currentValue("switch") == "off") : false String globalStatus = isPaused ? "PAUSED (Physical Master Switch Off)" : "ACTIVE" html += "
Hardware Interlock: ${globalStatus}
" long daysIdle = state.lastCleanTime ? ((now() - state.lastCleanTime) / 86400000) : 0 html += "
Time Since Last Dispatch: ${daysIdle} Days
" String roomHtml = "
" roomHtml += "" boolean roomsExist = false for (int i = 1; i <= 12; i++) { if (settings["enableRoom_${i}"] && settings["roomName_${i}"]) { roomsExist = true String rName = settings["roomName_${i}"] int occSecs = state["occSecs_${rName}"] ?: 0 int occMins = (occSecs / 60) as Integer int remainderSecs = occSecs % 60 int mEvents = state["motionEvents_${rName}"] ?: 0 String dStatus = state.lastDispatchLog ? (state.lastDispatchLog[rName] ?: "--") : "--" roomHtml += "" roomHtml += "" roomHtml += "" roomHtml += "" roomHtml += "" roomHtml += "" } } roomHtml += "
Room NameTotal ActivityMotion EventsLast Dispatch Status
${rName}${occMins}m ${remainderSecs}s${mEvents} Triggers${dStatus}
" if (roomsExist) html += roomHtml // --- UPDATED ROI CALCULATION BLOCK --- double v1Watts = getVacWatts(settings.vac1Model, settings.vac1Watts) double v2Watts = getVacWatts(settings.vac2Model, settings.vac2Watts) double currentV1Off = (state.dock1OffTime) ? ((now() - state.dock1OffTime) / 3600000.0) : 0.0 double currentV2Off = (state.dock2OffTime) ? ((now() - state.dock2OffTime) / 3600000.0) : 0.0 double totalV1Hours = (state.dock1OfflineHours ?: 0.0) + currentV1Off double totalV2Hours = (state.dock2OfflineHours ?: 0.0) + currentV2Off double v1KwhSaved = (totalV1Hours * v1Watts) / 1000.0 double v2KwhSaved = (totalV2Hours * v2Watts) / 1000.0 double totalKwhSaved = v1KwhSaved + v2KwhSaved double kwRate = settings.kwRate ?: 0.15 double v1MoneySaved = v1KwhSaved * kwRate double v2MoneySaved = v2KwhSaved * kwRate double totalMoneySaved = totalKwhSaved * kwRate // FIX: Removed the totalKwhSaved > 0 condition so the UI always renders. if (settings.smartROISavings) { html += "
" html += "Financial & Energy ROI: System has prevented a total of ${String.format("%.2f", totalKwhSaved)} kWh of phantom draw, saving \$${String.format("%.2f", totalMoneySaved)}.
" // Always show the breakdown if the vacuums exist in the settings html += "
" if (vacuum1) html += "↳ Vacuum 1: ${String.format("%.2f", v1KwhSaved)} kWh saved (\$${String.format("%.2f", v1MoneySaved)})
" if (vacuum2) html += "↳ Vacuum 2: ${String.format("%.2f", v2KwhSaved)} kWh saved (\$${String.format("%.2f", v2MoneySaved)})" html += "
" html += "
" } return html }