[JSR223][Jython]Washing machine monitoring script, time + threshold based

Howdy! Here’s a script I’ve written to monitor my washing machines and send a notification when the laundry is done. It’s not a unique premise for sure, but it may possibly be easier to set up than threshold-based state machines, since it relies on delayed reaction rather than exact power usage. Posting it in the hope that it’s some use to somebody. Would also love suggestions of simplifications, particularly if there’s a way to generalize the rule creation and keep it inside the class.

# This script monitors one or more washing machines (power usage), based on TIME as well as power threshold,
# and sends a Broadcast Notification when the cycle is done.
# Version 2

from core.rules import rule
from core.triggers import when

from threading import Timer
from org.joda.time import DateTime

from org.openhab.io.openhabcloud import NotificationAction

from core.log import logging, LOG_PREFIX
log = logging.getLogger(LOG_PREFIX + ".WashingMachines")

def logprint(text):
	log.debug(text)



class WashingMachineMonitor(object):
	def __init__(self, strItemname, strMessage):
		self.strItemname=strItemname
		self.strMessage=strMessage

		self.hTimerActivate=None
		self.hTimerDeactivate=None

		self.bActive=False

		self.bUsingPower=False

		self.fThreshold=5 #watts
		self.fActivateTimeout=30 #seconds to realize we're doing laundry
		self.fDeactivateTimeout=180 #seconds to realize laundry is done

	def fnTimerActivate(self):
		logprint("post-activate " + self.strItemname)
		self.hTimerActivate=None
		
		# We've been on for a while, we must be doing laundry
		self.bActive=True	
		logprint(self.strItemname + " entering washing cycle")

	def fnTimerDeactivate(self):
		logprint("post-deactivate " + self.strItemname)
		self.hTimerDeactivate=None

		# We've been off for a while. Were we on long enough to be doing laundry?
		if self.bActive:
			# Yes. Laundry cycle is done!
			self.bActive=False

			logprint(self.strItemname + " laundry cycle done!")

			# Send the notification through the openHAB cloud binding
			NotificationAction.sendBroadcastNotification(self.strMessage)


	def fnActivity(self,fPowerWatts):

		#logprint(self.strItemname + " currently using " + str(fPowerWatts))


		bUsingPowerNow=True if fPowerWatts > self.fThreshold else False

		if self.bUsingPower != bUsingPowerNow:
			self.bUsingPower = bUsingPowerNow

			if self.hTimerActivate:
				self.hTimerActivate.cancel()
				self.hTimerActivate=None

			if self.hTimerDeactivate:
				self.hTimerDeactivate.cancel()
				self.hTimerDeactivate=None

			if self.bUsingPower:
				#logprint(self.strItemname + " start activate timer, using " + str(fPowerWatts))
				self.hTimerActivate=Timer(self.fActivateTimeout, self.fnTimerActivate)
				self.hTimerActivate.start()
			else:
				#logprint(self.strItemname + " start deactivate timer, using " + str(fPowerWatts))
				self.hTimerDeactivate=Timer(self.fDeactivateTimeout, self.fnTimerDeactivate)
				self.hTimerDeactivate.start()





# Define how many appliances to monitor here.
# The first parameter is what item to monitor.
# However, it is not used yet because I wasn't sure how to define the rules in a generic manner. 
# I included the parameter for a future update.

# The second parameter is the message to send, in this case using a unicode string.

TopLoader = WashingMachineMonitor("WashingMachineTopLoaderWatts",u"เครื่องซักผ้าเสร็จแล้วครับ (ฝาบน)")
FrontLoader = WashingMachineMonitor("WashingMachineFrontLoaderWatts",u"เครื่องซักผ้าเสร็จแล้วครับ (ฝาหน้า)")



# Since I'm not yet sure how to define a rule in a generic manner, we define one for each washing machine.
# It should be triggered by the power usage of each appliance, and sending the power usage in watts to the
# respective object through the fnActivity function

@rule("WashingMachineTopLoaderWatts", description="", tags=["utility"])
@when("Item WashingMachineTopLoaderWatts received update")
def WashingMachineTopLoaderWatts_Decorators(event):
	TopLoader.fnActivity(event.itemState.floatValue())

@rule("WashingMachineFrontLoaderWatts", description="", tags=["utility"])
@when("Item WashingMachineFrontLoaderWatts received update")
def WashingMachineFrontLoaderWatts_Decorators(event):
	FrontLoader.fnActivity(event.itemState.floatValue())

Original version of my script for posterity
# This script monitors one or more washing machines (power usage), based on TIME as well as power threshold,
# and sends a Broadcast Notification when the cycle is done.
# Version 1

from org.slf4j import LoggerFactory

from core.rules import rule
from core.triggers import when

from threading import Timer
from org.joda.time import DateTime

from org.openhab.io.openhabcloud import NotificationAction

def logprint(text):
	pass
	LoggerFactory.getLogger("org.eclipse.smarthome.automation.examples").info(text)



class WashingMachineMonitor(object):
	def __init__(self, strItemname, strMessage):
		self.strItemname=strItemname
		self.strMessage=strMessage

		self.hTimerActivate=None
		self.hTimerDeactivate=None

		self.bActive=False

		self.bUsingPower=False

		self.fThreshold=5 #watts
		self.fActivateTimeout=30 #seconds to realize we're doing laundry
		self.fDeactivateTimeout=180 #seconds to realize laundry is done

	def fnTimerActivate(self):
		logprint("post-activate " + self.strItemname)
		self.hTimerActivate=None
		
		# We've been on for a while, we must be doing laundry
		self.bActive=True	
		logprint(self.strItemname + " entering washing cycle")

	def fnTimerDeactivate(self):
		logprint("post-deactivate " + self.strItemname)
		self.hTimerDeactivate=None

		# We've been off for a while. Were we on long enough to be doing laundry?
		if self.bActive:
			# Yes. Laundry cycle is done!
			self.bActive=False

			logprint(self.strItemname + " laundry cycle done!")

			"""
			Currently, JSR223/Jython in OpenHAB cannot handle unicode strings, but Rules DSL can, so we don't send the notification directly.
			Instead, we hand the job off to a rule, so that we can change the message along the way.
			
			First we define an item (in a .items file)
			String NotifyLeif "Send string to Leif's mobile"

			Then we define a rule (in a .rules file):

			rule "NotifyLeif rule"
			when
				Item NotifyLeif received update
			then

				var strMessage=NotifyLeif.state.toString

				logInfo("rule","NotifyLeif: " + strMessage);

				switch(NotifyLeif.state.toString)
				{
					case "TOPLOADER":
						strMessage="เครื่องซักผ้าเสร็จแล้วครับ (ฝาบน)"
					case "FRONTLOADER":
						strMessage="เครื่องซักผ้าเสร็จแล้วครับ (ฝาหน้า)"
				}

				sendBroadcastNotification(strMessage)

			end

			"""

			#NotificationAction.sendBroadcastNotification(self.strMessage)

			events.postUpdate(ir.getItem("NotifyLeif"),self.strMessage)



	def fnActivity(self,fPowerWatts):

		#logprint(self.strItemname + " currently using " + str(fPowerWatts))


		bUsingPowerNow=True if fPowerWatts > self.fThreshold else False

		if self.bUsingPower != bUsingPowerNow:
			self.bUsingPower = bUsingPowerNow

			if self.hTimerActivate:
				self.hTimerActivate.cancel()
				self.hTimerActivate=None

			if self.hTimerDeactivate:
				self.hTimerDeactivate.cancel()
				self.hTimerDeactivate=None

			if self.bUsingPower:
				#logprint(self.strItemname + " start activate timer, using " + str(fPowerWatts))
				self.hTimerActivate=Timer(self.fActivateTimeout, self.fnTimerActivate)
				self.hTimerActivate.start()
			else:
				#logprint(self.strItemname + " start deactivate timer, using " + str(fPowerWatts))
				self.hTimerDeactivate=Timer(self.fDeactivateTimeout, self.fnTimerDeactivate)
				self.hTimerDeactivate.start()





# Define how many appliances to monitor here.
# The first parameter is what item to monitor. However, it is not used yet because I wasn't sure how to define the rules in a generic manner.
# I included the parameter for a future update.

TopLoader = WashingMachineMonitor("WashingMachineTopLoaderWatts","TOPLOADER")
FrontLoader = WashingMachineMonitor("WashingMachineFrontLoaderWatts","FRONTLOADER")



# Since I'm not yet sure how to define a rule in a generic manner, we define one for each washing machine.
# It should be triggered by the power usage of each appliance, and sending the power usage in watts to the
# respective object through the fnActivity function

@rule("WashingMachineTopLoaderWatts", description="", tags=["utility"])
@when("Item WashingMachineTopLoaderWatts received update")
def WashingMachineTopLoaderWatts_Decorators(event):
	TopLoader.fnActivity(event.itemState.floatValue())

@rule("WashingMachineFrontLoaderWatts", description="", tags=["utility"])
@when("Item WashingMachineFrontLoaderWatts received update")
def WashingMachineFrontLoaderWatts_Decorators(event):
	FrontLoader.fnActivity(event.itemState.floatValue())

5 Likes

If you’re using the helper libraries, using core.log might be cleaner.

This is not correct. There are some examples in ideAlarm of how it can be done.

For rules like this, I make a dictionary of timers and put the logic in the function instead of using classes, which looks to over complicate things. I do something like this for turning off TVs when there is no activity in the room and the power level has dropped below a threshold (screen saver goes to black). I do something similar for washer, dryer and dishwasher notifications, but use persistence for the debouncing rather than timers.

This might work for you…

washing_machines = {
    TopLoader: WashingMachineMonitor("WashingMachineTopLoaderWatts","TOPLOADER"),
    FrontLoader: WashingMachineMonitor("WashingMachineFrontLoaderWatts","FRONTLOADER")
}

@rule("WashingMachineTopLoaderWatts", description="", tags=["utility"])
@when("Item WashingMachineTopLoaderWatts received update")
@when("Item WashingMachineFrontLoaderWatts received update")
def WashingMachine_Decorators(event):
	washingMachines[event.itemName].fnActivity(event.itemState.floatValue())

Please think about submitting your example to the community section of the helper library repo :slight_smile:. We are about to wrap up a major documentation overhaul and then I plan to submit a while lot of examples like this!

Wow! That is a ton cleaner indeed. Thank you for this. Is there by any chance a predefined constant that yields the python file name, something akin to FILE in C++?

log = logging.getLogger(LOG_PREFIX + ".workstation")

is nice, but

log = logging.getLogger(LOG_PREFIX + CURRENT_PYTHON_FILE)

would be even nicer. :slight_smile:

HA! Right you are, sir. And to think I was missing a single character… a lower-case character at that.
Future visitor: to define a unicode string in Python, don’t forget the “u”.
Like so:

strMessage=u"ถ้ารู้ตั้งแต่แรกก็จะดีกว่า"

That’s going to simplify my script a lot :).

I appreciate the syntax example – the dictionary will certainly come in handy for other tasks!

But, it’s still duplicates the WashingMachineTopLoaderWatts and WashingMachineFrontLoaderWatts identifiers.

Is there a way to define the rules based on the washing_machines dictionary, even if it’s more complicated? Even if it’s difficult to write the first time, I would really like to know this design pattern for future tasks.

I would love to! I actually googled and searched the forum for a good 10 minutes trying to find a good place to post this, I think I even used the word “library”, but didn’t find repo to which you are referring, so I gave up and posted on the forum. Could you share the link to this repo? :slight_smile:

This little tidbit almost slipped me by. I searched the forum and actually managed to find something relevant this time: Stop double press on button
Is this what you meant? I’m not sure it would work for the washing machines, because without a timer, when the wash cycle is done and the power usage goes to 0 there won’t be another update – so where would I check the last modification time without a timer to wake me up again?

Thank you Scott for another information-packed reply!

In Python, there are several ways to get to the name of the file name of the script or module, such as file. But for Jython scripts running in javax.script, I haven’t found a way to do it.

‘this’ is a byte string. Without telling the Jython interpreter how to decode it, a byte string will be decoded as ascii. These letters cannot be represented in ascii, so you’d get an error. u’this’ is a literal denoting a unicode object, which decodes using the default source encoding (utf8, unless you’ve changed it). You could also use…

strMessage=unicode("ถ้ารู้ตั้งแต่แรกก็จะดีกว่า", "utf8")

I don’t know what you mean by duplicated. I’m neck deep in setting up Sphinx and rewriting the documentation for the helper libraries. When that is done, I’ll add some more examples in the community section.

Same place you got the libraries from :wink:, although there have been some name changes recently… GitHub - openhab-scripters/openhab-helper-libraries: Scripts and modules for use with openHAB.

I’m sorry… I completely misspoke… and I’ve done it before on this same topic :roll_eyes:. I used to use persistence, but I’m currently just using itemState, oldItemState and timers.

Ah! Here’s what I meant:

The same identifier (“WashingMachineTopLoaderWatts”) has to be typed (or copy/pasted) both when initializing the class instance, and when defining the rule.

If it were possible (maybe it is?) for the class instance to define and initialize the rule purely by function call, then the class could hand its identifier to the rules engine, so that it would only have to be typed ONCE. This reduces the risk of making mistakes and can make for cleaner code.

I’m sorry to be clueless – I haven’t used github for anything other than downloading/cloning before. I use git for my own development but I use a different, more private git cloud provider. I don’t have upload access to the openhab repository (obviously), do you mean to fork, modify, and create a pull request or something like that?

No sweat! You’re being extremely helpful, and I appreciate it.

Thanks for the explanation about unicode!
I’m a complete beginner at Python, but it seemed like the most viable route to go, because I don’t know java, javascript, node red or any of those other newfangled languages. C/C++ on the other hand, I write for a living. Python can be useful for other things, so it’s all good.

///Leif

It is possible. I use this approach with some of my generic button and light scene rules.

Class definitions:

class MetadataAccess(object):
    def __init__(self):
        pass

    def getMetadataConfig(self, item, md_config_key):
        metadata = MetadataRegistry.get(MetadataKey(md_config_key, item.name))
        return metadata.configuration if hasattr(metadata, 'configuration') else None

    def setMetadataConfig(self, item, md_config_key, value):
        metadataKey = MetadataKey(md_config_key, item.name)
        metadata = MetadataRegistry.get(metadataKey)
        if hasattr(metadata, 'configuration'):
            MetadataRegistry.remove(metadataKey)
        MetadataRegistry.add(Metadata(metadataKey, md_config_key, value))

    def deleteMetadataConfig(self, item, md_config_key):
        metadataKey = MetadataKey(md_config_key, item.name)
        metadata = MetadataRegistry.get(metadataKey)
        if hasattr(metadata, 'configuration'):
            MetadataRegistry.remove(metadataKey)

class SceneBase(MetadataAccess):
    def __init__(self, className, instName, group_name):
        MetadataAccess.__init__(self)
        self.logger = LoggerFactory.getLogger("org.eclipse.smarthome.model.script.Rules.%s" %
                                              className)
        self.logger.debug("Instantiating instance of class %s, instName: %s, group_name: %s" %
                          (className, instName, group_name))
        self.__name__ = instName
        self.group = ir.getItem(group_name)

    def updateLEDs(self, ledCmd, sceneMetadata):
        # turn ON|OFF the scene's keypad LEDs
        ledGroup = ir.getItem(sceneMetadata['ledGroup'])
        self.logger.debug("group state: %s, %s state: %s" %
                          (str(ledCmd), ledGroup.name, str(ledGroup.state)))
        if ledCmd != ledGroup.state:
            events.sendCommand(ledGroup, str(ledCmd))
            self.logger.debug("sendCommand(%s, %s)" % (ledGroup.name, ledCmd))

class AllOffToggleMonitorSceneMonitor(SceneBase):
    def __init__(self, instName, scene_monitors_group_name):
        SceneBase.__init__(self,
                           self.__class__.__name__,
                           instName,
                           scene_monitors_group_name)

    def __call__(self, event):
        self.logger.debug("'%s' triggered" % self.__name__)

        sceneGroup = ir.getItem(event.itemName)
        self.logger.debug("scene load group: %s changed to: %s" %
                          (sceneGroup.name, event.itemState))

        sceneMetadata = self.getMetadataConfig(sceneGroup, 'scene')
        if sceneMetadata is not None:
            self.updateLEDs(sceneGroup.state, sceneMetadata)

class SingleToggleSceneMonitor(SceneBase):
    def __init__(self, instName, scene_monitors_group_name):
        SceneBase.__init__(self,
                           self.__class__.__name__,
                           instName,
                           scene_monitors_group_name)

    def __call__(self, event):
        self.logger.debug("'%s' triggered" % self.__name__)

        triggeringItem = ir.getItem(event.itemName)
        sceneMonitorGroups = map(lambda sceneMonitorGroup: sceneMonitorGroup.name,
                                 self.group.members)
        # iterate over each group that was triggered
        for sceneGroup in map(lambda sGroup: ir.getItem(sGroup),
                              list(group for group in triggeringItem.getGroupNames()
                                   if group in sceneMonitorGroups)):
            self.logger.debug("scene load group: %s updated to %s by %s" %
                              (sceneGroup.name,
                               event.itemState,
                               triggeringItem.name))
            sceneMetadata = self.getMetadataConfig(sceneGroup, 'scene')
            if sceneMetadata is not None:
                loadConfig = sceneMetadata['loads']
                ledCmd = OFF if str(event.itemState) != loadConfig[event.itemName]       \
                                or len(list(load                                         \
                                            for load in sceneGroup.members               \
                                            if loadConfig[load.name] != str(load.state)))\
                             else ON
                self.updateLEDs(ledCmd, sceneMetadata)

Rule instantiation:

################################################################################
# set up scenes and instantiate all required rules:
try:
    scenes_setup = ScenesSetup('_Scene_Monitors', '_Scene_Buttons', KeypadScenes)

    ############################################################################
    # now that the scenes have been set up, instantiate the required
    # scene monitor and button rules:
    monitor_modes = []
    scene_monitor_rules = []
    scene_button_rules  = []
    ############################################################################
    # instantiate required scene monitor rules:
    for mode in scenes_setup.scene_modes:
        rule_prefix = "".join(AO_TM_MODES if mode in AO_TM_MODES else S_T_MODES)
        if rule_prefix not in monitor_modes:
            sm_when_expr = "%s" % ("Member of %s changed" %                     \
                                   scenes_setup.ao_tm_scene_monitor_groups_name \
                                       if mode in AO_TM_MODES else              \
                                   "Descendent of %s received update" %         \
                                   scenes_setup.s_t_scene_monitor_groups_name)
            monitor_modes.append(rule_prefix)
            scene_monitor_rules.append(
                rule("%s Scene Monitor" % rule_prefix)(
                when(sm_when_expr)
                    (AllOffToggleMonitorSceneMonitor("%s_SceneMonitor" % rule_prefix,
                                                     scenes_setup.ao_tm_scene_monitor_groups_name) \
                         if mode in AO_TM_MODES else                                               \
                     SingleToggleSceneMonitor("%s_SceneMonitor" % rule_prefix,
                                              scenes_setup.s_t_scene_monitor_groups_name))))
            logger.debug("instantiated %s monitor rule" % rule_prefix)

        ########################################################################
        # instantiate required scene button rules:
        rule_prefix = mode
        scene_button_groups_name = scenes_setup.ao_scene_button_groups_name \
                                       if mode == ALL_OFF else              \
                                   scenes_setup.tm_scene_button_groups_name \
                                       if mode == TOGGLE_MONITOR else       \
                                   scenes_setup.s_scene_button_groups_name  \
                                       if mode == SINGLE else               \
                                   scenes_setup.t_scene_button_groups_name
        scene_button_rules.append(
            rule("%s Scene Button Handler" % rule_prefix)(
            when("Member of %s changed to ON" % scene_button_groups_name)
                (AllOffSceneButtonHandler("%s_SceneButtonHandler" % rule_prefix,
                                          scene_button_groups_name) \
                     if mode == ALL_OFF else                        \
                 ToggleMonitorSceneButtonHandler("%s_SceneButtonHandler" %
                                                 rule_prefix,
                                                 scene_button_groups_name) \
                     if mode == TOGGLE_MONITOR else                        \
                 SingleSceneButtonHandler("%s_SceneButtonHandler" % rule_prefix,
                                          scene_button_groups_name) \
                     if mode == SINGLE else                         \
                 ToggleSceneButtonHandler("%s_SceneButtonHandler" % rule_prefix,
                                          scene_button_groups_name))))
        logger.debug("instantiated %s button rule" % rule_prefix)
except Exception as e:
    import traceback
    logger.error("Exception %s: %s" % (e, traceback.format_exc()))
else:
    logger.info("ready for testing")
1 Like

WOW! Now that’s what I’m talking about. It’s going to take me more than a bit to unpack that, but that’s definitely the information I needed. Thanks @scottk!

There are some references in those snippets that may help you unpacking the script. I’m happy to PM a copy of the complete script to you if you like.

Cheers!

1 Like

Thanks Scott. Actually, the code you pasted was enough! I got it working!

Here’s what remains after I stripped it down into a canonical example:

ruletest.py

from core.rules import rule
from core.triggers import when

from core.log import logging, LOG_PREFIX
log = logging.getLogger(LOG_PREFIX + ".ruletest")


class OnRuleTriggered(object):
	def __init__(self,title):
		self.title=title
		log.debug("OnRuleTriggered %s created" % self.title)

	def __call__(self, event):
		log.debug("RULE %s TRIGGERED BY %s" % (self.title,event.itemName))

class LabTest(object):
	def __init__(self):
		self.myrule=rule("My Button Handler")(
			when("Item MyButton received update")
			(OnRuleTriggered("FOO")))



lab=LabTest()

Result:
2019-06-15 07:57:17.639 [DEBUG] [jsr223.jython.ruletest ] - RULE FOO TRIGGERED BY MyButton
2019-06-15 07:57:18.326 [DEBUG] [jsr223.jython.ruletest ] - RULE FOO TRIGGERED BY MyButton

That really wasn’t all that painful. I didn’t realize you could use the decorators in this way. This is totally usable!