├── BetterLaundryMonitor_Child.groovy ├── BetterLaundryMonitor_Parent.groovy ├── LICENSE ├── README.md └── docs ├── Hubitat-BetterLaundryMonitor.json ├── version2.json └── versions.json /BetterLaundryMonitor_Child.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Hubitat Import URL: https://raw.githubusercontent.com/HubitatCommunity/Hubitat-BetterLaundryMonitor/master/BetterLaundryMonitor_Child.groovy 3 | */ 4 | 5 | /** 6 | * Alert on Power Consumption 7 | * 8 | * Copyright 2015 Kevin Tierney, C Steele 9 | * 10 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 11 | * in compliance with the License. You may obtain a copy of the License at: 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 16 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 17 | * for the specific language governing permissions and limitations under the License. 18 | * 19 | * 20 | * 21 | * csteele: v1.5.0 Add Contact sensor child 22 | * Remove UpdateCheck, rely on HPM to check for a new version. 23 | * 24 | */ 25 | 26 | public static String version() { return "v1.5.0" } 27 | 28 | 29 | import groovy.time.* 30 | 31 | definition( 32 | name: "Better Laundry Monitor - Power Switch", 33 | namespace: "tierneykev", 34 | author: "Kevin Tierney, ChrisUthe, CSteele", 35 | description: "Child: powerMonitor capability, monitor the laundry cycle and alert when it's done.", 36 | category: "Green Living", 37 | 38 | parent: "tierneykev:Better Laundry Monitor", 39 | 40 | iconUrl: "", 41 | iconX2Url: "", 42 | iconX3Url: "" 43 | ) 44 | 45 | 46 | preferences { 47 | page (name: "mainPage") 48 | page (name: "sensorPage") 49 | page (name: "thresholdPage") 50 | page (name: "informPage") 51 | } 52 | //
${myText}
53 | 54 | 55 | def mainPage() { 56 | dynamicPage(name: "mainPage", install: true, uninstall: true) { 57 | updateMyLabel() 58 | section("

${app.label ?: app.name}

"){ 59 | if (!atomicState.isPaused) { 60 | input(name: "pauseButton", type: "button", title: "Pause", backgroundColor: "Green", textColor: "white", submitOnChange: true) 61 | } else { 62 | input(name: "resumeButton", type: "button", title: "Resume", backgroundColor: "Crimson", textColor: "white", submitOnChange: true) 63 | } 64 | } 65 | section("-= Main Menu =-") 66 | { 67 | input (name: "deviceType", title: "Type of Device", type: "enum", options: [powerMeter:"Power Meter", accelerationSensor:"Sequence Vibration Sensor", accelSensor:"Timed Vibration Sensor", contactSensor:"Contact Sensor"], required:true, submitOnChange:true) 68 | } 69 | 70 | if (deviceType) { 71 | section 72 | { 73 | href "sensorPage", title: "Sensors", description: "Sensors to be monitored", state: selectOk?.sensorPage ? "complete" : null 74 | href "thresholdPage", title: "Thresholds", description: "Thresholds to be monitored", state: selectOk?.thresholdPage ? "complete" : null 75 | href "informPage", title: "Inform", description: "Who and what to Inform", state: selectOk?.informPage ? "complete" : null 76 | } 77 | } 78 | 79 | section (title: "Reset/End Cycle") { 80 | input(name: "resetButton", type: "button", title: "Reset", backgroundColor: "Crimson", textColor: "white", submitOnChange: true) 81 | } 82 | section (title: "Name/Rename") { 83 | label title: "This child app's Name (optional)", required: false, submitOnChange: true 84 | if (!app.label) { 85 | app.updateLabel(app.name) 86 | atomicState.appDisplayName = app.name 87 | } 88 | if (app.label.contains('When this device starts/stops drawing power") { 108 | input "pwrMeter", "capability.powerMeter", title: "Power Meter" , multiple: false, required: false, defaultValue: null 109 | } 110 | } 111 | if (deviceType == "accelerationSensor" || deviceType == "accelSensor") { 112 | section("When vibration stops on this device") { 113 | input "accelSensor", "capability.accelerationSensor", title: "Acceleration Sensor" , multiple: false, required: false, defaultValue: null 114 | } 115 | } 116 | if (deviceType == "contactSensor") { 117 | section("When Contct stops on this device") { 118 | input "contactSensor", "capability.contactSensor", title: "Contact Sensor" , multiple: false, required: false, defaultValue: null 119 | } 120 | } 121 | } 122 | } 123 | 124 | 125 | def thresholdPage() { 126 | dynamicPage(name: "thresholdPage") { 127 | if (deviceType == "accelerationSensor") { 128 | section("Vibration Thresholds", hidden: false, hideable: true) { 129 | input "delayEndAcc", "number", title: "Stop after no vibration for this many sequential reportings:", defaultValue: "2", required: false 130 | input "cycleMax", "number", title: "Optional: Maximum cycle time (acts as a deadman timer.)", required: false 131 | } 132 | } 133 | if (deviceType == "powerMeter") { 134 | section ("Power Thresholds", hidden: false, hideable: true) { 135 | input "startThreshold", "decimal", title: "Start cycle when power raises above (W)", defaultValue: "8", required: false 136 | input "endThreshold", "decimal", title: "Stop cycle when power drops below (W)", defaultValue: "4", required: false 137 | input "delayEndPwr", "number", title: "Stop after power has been below the threshold for this many sequential reportings:", defaultValue: "2", required: false 138 | input "delayEndDelay", "number", title: "Stop after power has been below the threshold for this many continuous minutes:", defaultValue: "0", required: false 139 | input "ignoreThreshold", "decimal", title: "Optional: Ignore extraneous power readings above (W)", defaultValue: "1500", required: false 140 | input "startTimeThreshold", "number", title: "Optional: Time (in minutes) to wait before counting power threshold. Great for pre-wash soaks.", required: false 141 | input "cycleMax", "number", title: "Optional: Maximum cycle time (acts as a deadman timer.)", required: false 142 | } 143 | } 144 | if (deviceType == "accelSensor") { 145 | section("Time Thresholds (in minutes)", hidden: false, hideable: true) { 146 | input "fillTime", "decimal", title: "Time to fill tub (0 for Dryer)", required: false, defaultValue: 5 147 | input "cycleTime", "decimal", title: "Minimum cycle time", required: false, defaultValue: 10 148 | input "cycleMax", "number", title: "Optional: Maximum cycle time (acts as a deadman timer.)", required: false 149 | } 150 | } 151 | if (deviceType == "contactSensor") { 152 | section ("Contact Thresholds", hidden: false, hideable: true) { 153 | input "contCycleCount", "number", title: "Stop after this many cycles:", defaultValue: "2", required: false 154 | input "cycleMax", "number", title: "Optional: Maximum cycle time (acts as a deadman timer.)", required: false 155 | } 156 | } 157 | } 158 | } 159 | 160 | 161 | def informPage() { 162 | dynamicPage(name: "informPage") { 163 | section ("Send this message", hidden: false, hideable: true) { 164 | input "messageStart", "text", title: "Notification message Start (optional)", description: "Laundry is started!", required: false 165 | input "message", "text", title: "Notification message End", description: "Laundry is done!", required: true 166 | } 167 | section (title: "Using this Notification Method", hidden: false, hideable: true) { 168 | input "textNotification", "capability.notification", title: "Send Via: (Notification)", multiple: true, required: false 169 | input "speechOut", "capability.speechSynthesis", title:"Speak Via: (Speech Synthesis)", multiple: true, required: false 170 | input "player", "capability.musicPlayer", title:"Speak Via: (Music Player -> TTS)", multiple: true, required: false 171 | input "blockIt", "capability.switch", title: "Switch to Block Speak if ON", multiple: false, required: false 172 | } 173 | section ("Choose Additional Devices") { 174 | input "switchList", "capability.switch", title: "Which Switches?", description: "Switches to follow the active state", multiple: true, hideWhenEmpty: false, required: false 175 | } 176 | } 177 | } 178 | 179 | 180 | def getSelectOk() 181 | { 182 | def status = 183 | [ 184 | sensorPage: pwrMeter ?: accelSensor ?: contactSensor, 185 | thresholdPage: cycleTime ?: fillTime ?: startThreshold ?: endThreshold ?: delayEndAcc ?: delayEndPwr ?: contCycleCount, 186 | informPage: messageStart?.size() ?: message?.size() 187 | ] 188 | status << [all: status.sensorPage ?: status.thresholdPage ?: status.informPage] 189 | } 190 | 191 | 192 | def powerHandler(evt) { 193 | def latestPower = pwrMeter.currentValue("power") 194 | def delayEPloop = delayEndPwr-1 195 | 196 | if (debugOutput) log.debug "Power: ${latestPower}W, State: ${atomicState.cycleOn}, thresholds: ${startThreshold} ${endThreshold} ${delayEndPwr} ${delayEndDelay} optional: ${ignoreThreshold} ${startTimeThreshold} ${cycleMax}" 197 | 198 | if (latestPower > endThreshold && atomicState.cycleEnding) { 199 | atomicState.cycleEnd = -1 200 | atomicState.cycleEnding = false 201 | if (debugOutput) log.debug "Resetting end timer" 202 | } 203 | else if (!atomicState.cycleOn && (latestPower >= startThreshold) && (latestPower < ignoreThreshold)) { // latestpower < 1000: eliminate spikes that trigger false alarms 204 | send(messageStart) 205 | atomicState.cycleOn = true 206 | atomicState.cycleEnding = false 207 | atomicState.cycleStart = now() 208 | updateMyLabel() 209 | if (descTextEnable) log.info "Cycle started." 210 | if(switchList) { switchList*.on() } 211 | if (cycleMax) { // start the deadman timer 212 | def delay = Math.floor(cycleMax * 60).toInteger() 213 | runIn(delay, checkCycleMax) 214 | } 215 | } 216 | //If Start Time Threshold was set, check if we have waited that number of minutes before counting the power thresholds 217 | else if (startTimeThreshold && delayPowerThreshold()) { 218 | //do nothing 219 | if (latestPower < endThreshold) { 220 | atomicState.cycleOn = false 221 | atomicState.powerOffDelay = 0 222 | state.remove("startedAt") 223 | if (descTextEnable) log.info "Dropped below threshold before start time threshold, cancelling." 224 | } 225 | } 226 | //first time we are below the threshold, hold and wait for X more. 227 | else if (atomicState.cycleOn && latestPower < endThreshold && atomicState.powerOffDelay < delayEPloop){ 228 | atomicState.powerOffDelay++ 229 | if (debugOutput) log.debug "We hit Power Delay ${atomicState.powerOffDelay} times" 230 | } 231 | //Reset Delay if it only happened once 232 | else if (atomicState.cycleOn && latestPower >= endThreshold && atomicState.powerOffDelay != 0) { 233 | if (debugOutput) log.debug "We hit the Power Delay ${atomicState.powerOffDelay} times but cleared it" 234 | atomicState.powerOffDelay = 0 235 | } 236 | // If the Machine stops drawing power for X times in a row, the cycle is complete, send notification 237 | // or schedule future check if cycleEnd isn't set already 238 | else if (atomicState.cycleOn && (latestPower < endThreshold) && (atomicState.cycleEnding != true)) { 239 | // cycleDone already scheduled if cycleEnd is set already 240 | atomicState.cycleEnding = true 241 | atomicState.cycleEnd = now() 242 | if (delayEndDelay > 0) { 243 | if (debugOutput) log.debug "Ending duration is set, waiting" 244 | runIn(delayEndDelay*60, cycleDone) 245 | } 246 | else 247 | { 248 | send(message) 249 | atomicState.cycleOn = false 250 | updateMyLabel() 251 | atomicState.powerOffDelay = 0 252 | state.remove("startedAt") 253 | atomicState.cycleEnd = -1 254 | atomicState.cycleEnding = false 255 | if (descTextEnable) log.info "Cycle finished." 256 | if(switchList) { switchList*.off() } 257 | } 258 | 259 | } 260 | } 261 | 262 | 263 | def cycleDone() { 264 | if (atomicState.cycleEnd != -1 && now() - atomicState.cycleEnd > (delayEndDelay*60)-10000) { 265 | send(message) 266 | atomicState.cycleOn = false 267 | atomicState.cycleEnding = false 268 | atomicState.cycleEnd = now() 269 | updateMyLabel() 270 | atomicState.powerOffDelay = 0 271 | state.remove("startedAt") 272 | if (debugOutput) log.debug "Cycle finished after delay." 273 | if(switchList) { switchList*.off() } 274 | } 275 | else { 276 | if (debugOutput) log.debug "Power resumed during timeout" 277 | } 278 | } 279 | 280 | 281 | def delayPowerThreshold() { 282 | def answer = false 283 | 284 | if (!state.startedAt) { 285 | state.startedAt = now() 286 | answer = true 287 | } else { 288 | def startTimeThresholdMsec = startTimeThreshold * 60000 289 | def duration = now() - state.startedAt 290 | if (startTimeThresholdMsec > duration) { 291 | answer = true 292 | } 293 | } 294 | 295 | return answer 296 | } 297 | 298 | 299 | def accelerationHandler(evt) { 300 | latestAccel = (evt.value == 'active') ? true : false 301 | if (debugOutput) log.debug "$evt.value, isRunning: $state.isRunning, evt: $latestAccel" 302 | 303 | if (!state.isRunning && latestAccel) { 304 | if (descTextEnable) log.info "Cycle started, arming detector" 305 | state.isRunning = true 306 | state.startedAt = now() 307 | atomicState.cycleOn = true 308 | atomicState.cycleStart = now() 309 | updateMyLabel() 310 | if (cycleMax) { // start the deadman timer 311 | def delay = Math.floor(cycleMax * 60).toInteger() 312 | runIn(delay, checkCycleMax) 313 | } 314 | if (switchList) switchList*.on() 315 | send(messageStart) 316 | } 317 | //first time we are go inactive, hold and wait for X more. 318 | else if (state.isRunning && !latestAccel && state.accelOffDelay < (delayEndAcc-1)) { 319 | state.accelOffDelay++ 320 | if (debugOutput) log.debug "We hit Acceleration Delay ${state.accelOffDelay} times" 321 | } 322 | //Reset Delay if it only happened once 323 | else if (state.isRunning && latestAccel && state.accelOffDelay != 0) { 324 | if (debugOutput) log.debug "We hit the Acceleration Delay ${state.accelOffDelay} times but cleared it" 325 | state.accelOffDelay = 0; 326 | } 327 | // If the Machine stops drawing power for X times in a row, the cycle is complete, send notification. 328 | else if (state.isRunning && !latestAccel) { 329 | send(message) 330 | state.isRunning = false 331 | atomicState.cycleEnd = now() 332 | atomicState.cycleOn = false 333 | state.accelOffDelay = 0 334 | updateMyLabel() 335 | if (descTextEnable) log.info "Cycle finished." 336 | if(switchList) { switchList*.off() } 337 | } 338 | 339 | } 340 | 341 | 342 | /* 343 | checkCycleMax 344 | 345 | If acceleration is being used, isRunning will be true. 346 | If power is being used, cycleOn will be true. 347 | IF contact is being used, cycleOn & isRunning will be true. 348 | 349 | */ 350 | def checkCycleMax() { 351 | if (state.isRunning) { 352 | send(message) 353 | state.isRunning = false 354 | atomicState.cycleEnd = now() 355 | atomicState.cycleOn = false 356 | state.accelOffDelay = 0 357 | updateMyLabel() 358 | if (descTextEnable) log.info "Cycle finished by deadman timer. State: ${state.isRunning}" 359 | if(switchList) { switchList*.off() } 360 | } 361 | if (atomicState.cycleOn) { 362 | send(message) 363 | atomicState.cycleOn = false 364 | atomicState.cycleEnd = now() 365 | atomicState.powerOffDelay = 0 366 | updateMyLabel() 367 | if (descTextEnable) log.info "Cycle finished by deadman timer. State: ${atomicState.cycleOn}" 368 | if(switchList) { switchList*.off() } 369 | } 370 | } 371 | 372 | 373 | // Thanks to ritchierich for these Acceleration methods 374 | def accelerationActiveHandler(evt) { 375 | if (debugOutput) log.debug "vibration, $evt.value" 376 | if (!state.isRunning) { 377 | if (descTextEnable) log.info "Cycle started, arming detector" 378 | state.isRunning = true 379 | state.startedAt = now() 380 | atomicState.cycleStart = now() 381 | atomicState.cycleOn = true 382 | updateMyLabel() 383 | if (cycleMax) { // start the deadman timer 384 | def delay = Math.floor(cycleMax * 60).toInteger() 385 | runIn(delay, checkCycleMax) 386 | } 387 | if (switchList) switchList*.on() 388 | send(messageStart) 389 | } 390 | state.stoppedAt = null 391 | } 392 | 393 | 394 | def accelerationInactiveHandler(evt) { 395 | if (debugOutput) log.debug "no vibration, $evt.value, isRunning: $state.isRunning, $state.accelOffDelay" 396 | if (state.isRunning && state.accelOffDelay >= (delayEndAcc)) { 397 | if (!state.stoppedAt) { 398 | state.stoppedAt = now() 399 | atomicState.cycleEnd = now() 400 | atomicState.cycleOn = false 401 | updateMyLabel() 402 | def delay = fillTime ? Math.floor(fillTime * 60).toInteger() : 2 403 | runIn(delay, checkRunningAccel, [overwrite: false]) 404 | } 405 | if (descTextEnable) log.info "Cycle finished, startedAt: ${state.startedAt}, stoppedAt: ${state.stoppedAt}" 406 | } 407 | } 408 | 409 | 410 | def contactHandler(evt) { 411 | latestContact = (evt.value == 'open') ? true : false 412 | def delayEPloop = contCycleCount-1 413 | 414 | if (latestContact) { 415 | if (!state.isRunning) { 416 | if (descTextEnable) log.info "Cycle started, arming detector" 417 | state.isRunning = true 418 | state.startedAt = now() 419 | atomicState.cycleStart = now() 420 | atomicState.cycleOn = true 421 | updateMyLabel() 422 | if (cycleMax) { // start the deadman timer 423 | def delay = Math.floor(cycleMax * 60).toInteger() 424 | runIn(delay, checkCycleMax) 425 | } 426 | if (switchList) switchList*.on() 427 | send(messageStart) 428 | } 429 | state.stoppedAt = null 430 | } 431 | else if (!latestContact) { 432 | //first time we are below the threshold, hold and wait for X more. 433 | if (atomicState.cycleOn && atomicState.contOffDelay < delayEPloop){ 434 | atomicState.contOffDelay++ 435 | if (debugOutput) log.debug "We hit Contact Delay ${atomicState.contOffDelay} times" 436 | } 437 | else if (state.isRunning && state.contOffDelay >= (delayEPloop)) { 438 | if (!state.stoppedAt) { 439 | state.stoppedAt = now() 440 | atomicState.cycleEnd = now() 441 | atomicState.cycleOn = false 442 | updateMyLabel() 443 | } 444 | if (descTextEnable) log.info "Cycle finished, startedAt: ${state.startedAt}, stoppedAt: ${state.stoppedAt}" 445 | } 446 | } 447 | 448 | if (debugOutput) log.debug "Contact Event: $evt.value, isRunning: $state.isRunning, $state.contOffDelay, latestContact: $latestContact, startTimeThreshold: $startTimeThreshold" 449 | } 450 | 451 | 452 | def checkRunningAccel() { 453 | if (debugOutput) log.debug "checkRunning() $state.accelOffDelay" 454 | if (state.isRunning) { 455 | // def fillTimeMsec = fillTime ? fillTime * 60000 : 300000 456 | def fillTimeMsec = fillTime ? fillTime * 60000 : 2000 457 | def sensorStates = accelSensor.statesSince("acceleration", new Date((now() - fillTimeMsec) as Long)) 458 | 459 | if (!sensorStates.find{it.value == "active"}) { 460 | def cycleTimeMsec = cycleTime ? cycleTime * 60000 : 600000 461 | def duration = now() - state.startedAt 462 | if (duration - fillTimeMsec > cycleTimeMsec) { 463 | // if(switchList) { switchList*.off() } 464 | atomicState.cycleEnd = now() 465 | if (debugOutput) log.debug "Sending cycle complete notification" 466 | send(message) 467 | } else { 468 | if (debugOutput) log.debug "Not sending notification because machine wasn't running long enough $duration versus $cycleTimeMsec msec" 469 | state.accelOffDelay = 0 470 | atomicState.cycleEnd = null // Change label to "idle" 471 | } 472 | state.isRunning = false 473 | atomicState.cycleOn = false 474 | updateMyLabel() 475 | if (switchList) switchList*.off() 476 | if (descTextEnable) log.info "Disarming detector" 477 | } else { 478 | if (debugOutput) log.debug "skipping notification because vibration detected again" 479 | state.accelOffDelay++ 480 | } 481 | } else { 482 | if (debugOutput) log.debug "machine no longer running" 483 | } 484 | } 485 | 486 | 487 | def checkRunningCont() { 488 | if (debugOutput) log.debug "checkRunning() $state.contOffDelay" 489 | if (state.isRunning) { 490 | // def startTimeThresholdMsec = startTimeThreshold ? startTimeThreshold * 60000 : 300000 491 | def startTimeThresholdMsec = startTimeThreshold ? startTimeThreshold * 60000 : 2000 492 | def sensorStates = contactSensor.statesSince("contactSensor", new Date((now() - startTimeThresholdMsec) as Long)) 493 | 494 | if (!sensorStates.find{it.value == "open"}) { 495 | def cycleTimeMsec = cycleTime ? cycleTime * 60000 : 600000 496 | def duration = now() - state.startedAt 497 | if (duration - startTimeThresholdMsec > cycleTimeMsec) { 498 | // if(switchList) { switchList*.off() } 499 | atomicState.cycleEnd = now() 500 | if (descTextEnable) log.info "Sending cycle complete notification" 501 | send(message) 502 | } else { 503 | if (debugOutput) log.debug "Not sending notification because machine wasn't running long enough $duration versus $cycleTimeMsec msec" 504 | state.contOffDelay = 0 505 | atomicState.cycleEnd = null // Change label to "idle" 506 | } 507 | state.isRunning = false 508 | atomicState.cycleOn = false 509 | updateMyLabel() 510 | if (switchList) switchList*.off() 511 | if (descTextEnable) log.info "Disarming detector" 512 | } else { 513 | if (debugOutput) log.debug "skipping notification because contact detected again" 514 | state.contOffDelay++ 515 | } 516 | } else { 517 | if (debugOutput) log.debug "machine no longer running" 518 | } 519 | } 520 | 521 | 522 | private send(msg) { 523 | if (!msg) return // no message 524 | if (textNotification) { textNotification*.deviceNotification(msg) } 525 | if (debugOutput) { log.debug "send: $msg" } 526 | if (state.blockItState) return // no noise please. 527 | if (speechOut) { speechOut*.speak(msg) } 528 | if (player){ player*.playText(msg) } 529 | } 530 | 531 | 532 | def installed() { 533 | // Initialize the states only when first installed... 534 | atomicState.cycleOn = null // we don't know if we're running yet 535 | atomicState.cycleEnding = null 536 | state.isRunning = null 537 | if (switchList) switchList*.off() 538 | atomicState.powerOffDelay = 0 539 | state.accelOffDelay = 0 540 | state.contOffDelay = 0 541 | 542 | initialize() 543 | app.clearSetting("debugOutput") // app.updateSetting() only updates, won't create. 544 | app.clearSetting("descTextEnable") 545 | if (descTextEnable) log.info "Installed with settings: ${settings}" 546 | } 547 | 548 | 549 | def updated() { 550 | unsubscribe() 551 | unschedule() 552 | initialize() 553 | if (blockIt) {subscribe(blockIt, "switch", blockItHandler)} 554 | if (descTextEnable) log.info "Updated with settings: ${settings}" 555 | } 556 | 557 | 558 | def initialize() { 559 | if (atomicState.isPaused) { 560 | updateMyLabel() 561 | return 562 | } 563 | reSubscribe() 564 | 565 | schedule("17 5 0 * * ?", updateMyLabel) // Fix the date string after the day changes 566 | updateMyLabel() 567 | 568 | // app.clearSetting("debugOutput") // app.updateSetting() only updates, won't create. 569 | // app.clearSetting("descTextEnable") // un-comment these, click Done then replace the // comment 570 | } 571 | 572 | 573 | def reSubscribe() { 574 | if (settings.deviceType == "powerMeter") { 575 | unsubscribe(accelSensor) 576 | unsubscribe(contactSensor) 577 | subscribe(pwrMeter, "power", powerHandler) 578 | if (debugOutput) log.debug "Cycle: ${atomicState.cycleOn} thresholds: ${startThreshold} ${endThreshold} ${delayEndPwr}/${delayEndAcc}" 579 | } 580 | else if (settings.deviceType == "accelerationSensor") { 581 | unsubscribe(pwrMeter) 582 | unsubscribe(contactSensor) 583 | subscribe(accelSensor, "acceleration", accelerationHandler) 584 | } 585 | else if (settings.deviceType == "accelSensor") { 586 | unsubscribe(pwrMeter) 587 | unsubscribe(contactSensor) 588 | subscribe(accelSensor, "acceleration.active", accelerationActiveHandler) 589 | subscribe(accelSensor, "acceleration.inactive", accelerationInactiveHandler) 590 | } 591 | else if (settings.deviceType == "contactSensor") { 592 | unsubscribe(pwrMeter) 593 | unsubscribe(accelSensor) 594 | subscribe(contactSensor, "contact.open", contactHandler) 595 | subscribe(contactSensor, "contact.closed", contactHandler) 596 | if (debugOutput) log.debug "Cycle: ${atomicState.cycleOn} thresholds: ${contCycleCount}" 597 | } 598 | } 599 | 600 | 601 | def appButtonHandler(btn) { 602 | switch(btn) { 603 | case "pauseButton": 604 | atomicState.isPaused = true 605 | updateMyLabel() 606 | break 607 | case "resumeButton": 608 | atomicState.isPaused = false 609 | updateMyLabel() 610 | break 611 | case "resetButton": 612 | state.isRunning = false 613 | atomicState.cycleEnd = now() 614 | atomicState.cycleOn = false 615 | state.accelOffDelay = 0 616 | state.contOffDelay = 0 617 | atomicState.cycleEnd = -1 618 | atomicState.cycleEnding = false 619 | updateMyLabel() 620 | unschedule(checkCycleMax) 621 | if (debugOutput) log.debug "Reset to Cycle finished." 622 | if(switchList) { switchList*.off() } 623 | break 624 | } 625 | } 626 | 627 | 628 | def blockItHandler(evt) { 629 | state?.blockItState = evt.value ? true : false 630 | } 631 | 632 | 633 | def setDebug(dbg, inf) { 634 | app.updateSetting("debugOutput",[value:dbg, type:"bool"]) 635 | app.updateSetting("descTextEnable",[value:inf, type:"bool"]) 636 | if (descTextEnable) log.info "debugOutput: $debugOutput, descTextEnable: $descTextEnable" 637 | } 638 | 639 | 640 | def display() 641 | { 642 | section { 643 | paragraph "\n
" 644 | paragraph "
Developed by: Kevin Tierney, ChrisUthe, C Steele, Barry Burke
Version Status: $state.Status
Current Version: ${version()} - ${thisCopyright}
" 645 | } 646 | } 647 | 648 | 649 | void updateMyLabel() { 650 | boolean ST = false 651 | String flag = ' (paused)' 667 | } else if (atomicState.cycleOn) { 668 | String beganAt = atomicState.cycleStart ? "started " + fixDateTimeString(atomicState.cycleStart) : 'running' 669 | newLabel = myLabel + " (${beganAt})" 670 | } else if ((atomicState.cycleOn != null) && (atomicState.cycleOn == false)) { 671 | String endedAt = atomicState.cycleEnd ? "finished " + fixDateTimeString(atomicState.cycleEnd) : 'idle' 672 | newLabel = myLabel + " (${endedAt})" 673 | } else { 674 | newLabel = myLabel 675 | } 676 | if (app.label != newLabel) app.updateLabel(newLabel) 677 | } 678 | 679 | 680 | String fixDateTimeString( eventDate) { 681 | def today = new Date(now()).clearTime() 682 | def target = new Date(eventDate).clearTime() 683 | 684 | String resultStr = '' 685 | String myDate = '' 686 | String myTime = '' 687 | boolean showTime = true 688 | 689 | if (target == today) { 690 | myDate = 'today' 691 | } else if (target == today-1) { 692 | myDate = 'yesterday' 693 | } else if (target == today+1) { 694 | myDate = 'tomorrow' 695 | } else if (dateStr == '2035-01-01' ) { // to Infinity 696 | myDate = 'a long time from now' 697 | showTime = false 698 | } else { 699 | myDate = 'on '+target.format('MM-dd') 700 | } 701 | if (showTime) { 702 | myTime = new Date(eventDate).format('h:mma').toLowerCase() 703 | } 704 | if (myDate || myTime) { 705 | resultStr = myTime ? "${myDate} at ${myTime}" : "${myDate}" 706 | } 707 | if (debugOutput) { log.debug "banner: ${resultStr}"} 708 | return resultStr 709 | } 710 | 711 | 712 | def getThisCopyright(){"© 2019 C Steele "} 713 | -------------------------------------------------------------------------------- /BetterLaundryMonitor_Parent.groovy: -------------------------------------------------------------------------------- 1 | /** 2 | * Hubitat Import URL: https://raw.githubusercontent.com/HubitatCommunity/Hubitat-BetterLaundryMonitor/master/BetterLaundryMonitor_Parent.groovy 3 | */ 4 | 5 | /** 6 | * Alert on Power Consumption 7 | * 8 | * Copyright 2015 Kevin Tierney, C Steele 9 | * 10 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 11 | * in compliance with the License. You may obtain a copy of the License at: 12 | * 13 | * http://www.apache.org/licenses/LICENSE-2.0 14 | * 15 | * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed 16 | * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License 17 | * for the specific language governing permissions and limitations under the License. 18 | * 19 | * 20 | * 21 | * csteele: v1.5.0 Add Contact sensor child 22 | * Remove UpdateCheck, rely on HPM to check for a new version. 23 | * 24 | */ 25 | 26 | public static String version() { return "v1.5.0" } 27 | 28 | 29 | definition( 30 | name: "Better Laundry Monitor", 31 | namespace: "tierneykev", 32 | author: "Kevin Tierney, CSteele", 33 | description: "Using a switch with powerMonitor capability, monitor the laundry cycle and alert when it starts or done.", 34 | category: "Green Living", 35 | iconUrl: "", 36 | iconX2Url: "", 37 | iconX3Url: "", 38 | ) 39 | 40 | 41 | preferences { 42 | page name: "mainPage", title: "", install: true, uninstall: true // ,submitOnChange: true 43 | } 44 | 45 | 46 | def mainPage() { 47 | dynamicPage(name: "mainPage") { 48 | section { 49 | paragraph title: "This parent app is a container for all:
Better Laundry Monitor - Power Switch child apps" 51 | } 52 | section (){app(name: "BlMpSw", appName: "Better Laundry Monitor - Power Switch", namespace: "tierneykev", title: "New Better Laundry Monitor - Power Switch App", multiple: true)} 53 | 54 | section (title: "Name/Rename") {label title: "Enter a name for this parent app (optional)", required: false} 55 | 56 | section ("Other preferences") { 57 | input "debugOutput", "bool", title: "Enable debug logging?", defaultValue: true 58 | input "descTextEnable","bool", title: "Enable descriptionText logging?", defaultValue: true 59 | } 60 | display() 61 | } 62 | } 63 | 64 | 65 | def installed() { 66 | log.debug "Installed with settings: ${settings}" 67 | initialize() 68 | } 69 | 70 | 71 | def updated() { 72 | log.debug "Updated with settings: ${settings}" 73 | unschedule() 74 | unsubscribe() 75 | if (debugOutput) runIn(1800,logsOff) 76 | initialize() 77 | } 78 | 79 | 80 | def initialize() { 81 | log.info "There are ${childApps.size()} child smartapps" 82 | childApps.each {child -> 83 | child.setDebug(debugOutput, descTextEnable) 84 | log.info "Child app: ${child.label}" 85 | } 86 | } 87 | 88 | 89 | def logsOff() { 90 | log.warn "debug logging disabled..." 91 | app?.updateSetting("debugOutput",[value:"false",type:"bool"]) 92 | } 93 | 94 | 95 | def display() { 96 | section{ 97 | paragraph "\n
" 98 | paragraph "
Developed by: Kevin Tierney, ChrisUthe, C Steele
Version Status: $state.Status
Current Version: ${version()} - ${thisCopyright}
" 99 | } 100 | } 101 | 102 | def getThisCopyright(){"© 2019 C Steele "} 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hubitat-BetterLaundryMonitor 2 | 3 |

Ported Kevin Tierney's SmartThings DTH to Hubitat Driver by @ChrisUthe. 4 |
Converted to Parent-Child App by cSteele. 5 |
Added @CobraVmax version check. 6 |
Miscellaneous Enhancements by Barry Burke. 7 |
Added readability to Parent and Child Apps and incorporated xxKeoxx's Child name/rename request. 8 |

Copy the 'raw' source files into Hubitat, parent then child. 9 |
Add New User App and pick the Parent code. (Child will be found by parent) 10 |
Open the App and build as many child apps as are needed, one for each washer or dryer. 11 |

12 | -------------------------------------------------------------------------------- /docs/Hubitat-BetterLaundryMonitor.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageName": "Hubitat-BetterLaundryMonitor", 3 | "releaseNotes": "v1.5.0 Add Contact sensor child\n Remove UpdateCheck, rely on HPM to check for a new version.\n1.4.10: Adjusted for better end of cycle. \n Added Reset button.\n1.4.9: Merged: Fix bug with power updates more frequent than delayEndDelay.", 4 | "documentationLink": "https://github.com/HubitatCommunity/Hubitat-BetterLaundryMonitor/blob/master/README.md", 5 | "author": "csteele", 6 | "minimumHEVersion": "1.4.10", 7 | "version": "1.5.0", 8 | "dateReleased": "2019-09-07", 9 | "apps": [ 10 | { 11 | "id": "ffddcbb2-5ac6-40df-a25c-3a8a3046cc96", 12 | "name": "Better Laundry Monitor", 13 | "namespace": "tierneykev", 14 | "location": "https://raw.githubusercontent.com/HubitatCommunity/Hubitat-BetterLaundryMonitor/master/BetterLaundryMonitor_Parent.groovy", 15 | "required": true, 16 | "oauth": false, 17 | "version": "1.5.0", 18 | "primary": true 19 | }, 20 | { 21 | "id": "3bba1cf8-a2c3-4017-a59c-bc29fca3d0dc", 22 | "name": "Better Laundry Monitor - Power Switch", 23 | "namespace": "tierneykev", 24 | "location": "https://raw.githubusercontent.com/HubitatCommunity/Hubitat-BetterLaundryMonitor/master/BetterLaundryMonitor_Child.groovy", 25 | "required": true, 26 | "oauth": false, 27 | "version": "1.5.0", 28 | "primary": false 29 | } 30 | ] 31 | } 32 | 33 | -------------------------------------------------------------------------------- /docs/version2.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "C Steele", 3 | "comment": "", 4 | "copyright": " Ⓒ 2018 cSteele", 5 | "application": { 6 | "BLMparent": {"ver": "1.5.0", "updated": "12/3/2023"}, 7 | "BLMchild": {"ver": "1.5.0", "updated": "12/3/2023"} 8 | }, "driver": { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "Comment": "", 3 | "copyright": " Ⓒ 2018 cSteele", 4 | "versions": { 5 | "Application": { 6 | "BLMparent": "1.5.0", 7 | "BLMchild": "1.5.0" 8 | }, 9 | "Driver": { 10 | }, 11 | 12 | "UpdateInfo": { 13 | "Application": { 14 | "BLMparent": "Updated: 12/03/2023", 15 | "BLMchild": "Updated: 12/03/2023" 16 | }, 17 | "Driver": { 18 | } 19 | } 20 | } 21 | } 22 | --------------------------------------------------------------------------------