Journey to JSR223 Python 8 of 9

I’m back with a few more Rules. This time I’ve converted some Rules that use executeCommandLine and use some core Java classes.

Nest Chart

I have a simple Rule that converts the Nest’s reported state to a Switch so I can chart whether the heating state is on or off against the temperatures.

Rules DSL

rule "Update Heating Switch for Charting"
when
      Item vNest_State received update
//then
      if(vNest_State.state != NULL && vNest_State.state.toString == "heating") vNest_Heating.postUpdate(ON)
end

Python

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

@rule("Nest heating switch", description="Update a proxy Item for the Nest state so we can chart when the heater is on.", tags=["hvac"])
@when("Item vNest_State received update")
def nest_state(event):
    events.postUpdate("vNest_Heating", "ON" if str(event.itemState) == "heating" else "OFF")

This Rule will go away when my new thermostat comes in I suspect.

Humidity Rules

I live in a dry climate and have a dumb humidifier in the bedroom. These Rules monitor the humidifiers (there is a smarter one in my son’s bedroom) and alerts if if seems they are not working (i.e. need to be refilled) and turns the dumb one on/off in our bedroom when it gets too dry.

Rules DSL

rule "Alert if humidity gets too low"
when
      Member of MinIndoorHumidity changed
then
      if(!(triggeringItem.state instanceof Number)) return;

      val alerted = ScriptServiceUtil.getItemRegistry.getItem(triggeringItem.name+"_Alerted")
      if(alerted.state != ON && (triggeringItem.state as Number) < 30) {
              aAlert.sendCommand("Remember to fill the humidifiers! " + triggeringItem.label + " is " + triggeringItem.state.toString + "%!")
              alerted.sendCommand(ON)
      }
end

rule "Turn on MBR Humidifier when necessary"
when
      Item vMBR_Humidity changed or
      Item vMBR_Target_Humidity changed
then
      //logInfo("Test", "Checking whether to turn on the humidifier")
      if(vMBR_Target_Humidity.state == NULL || vMBR_Target_Humidity.state == UNDEF) {
              vMBR_Target_Humidity.postUpdate(30|%)
              return;
      }
      if(vMBR_Humidity.state == NULL || vMBR_Humidity.state == UNDEF) {
              logWarn(logName, "Cannot adjust humidity in MBR, current reading is " + vMBR_Humidity.state)
              aMBR_Humidifier.sendCommand(OFF)
              return;
      }

      var newState = "Stay"
      val curr = vMBR_Humidity.state as QuantityType<Percent>
      val tgt  = vMBR_Target_Humidity.state as QuantityType<Percent>
      if(curr < tgt) {
              newState = "ON"
      }
      else if(curr > tgt + 2) {
              newState = "OFF"
      }

      if(newState != "Stay" && newState != aMBR_Humidifier.state.toString){
              logInfo("Test", "Changing the humidifier to " + newState)
              aMBR_Humidifier.sendCommand(newState)
      }
end

These rules are pretty straight forward. The second one shows some hysteresis.

Python

from core.rules import rule
from core.triggers import when
from core.metadata import get_key_value, set_metadata
from personal.util import send_alert, get_name
from core.utils import sendCommandCheckFirst

@rule("Humidity low", description="Alert of the humidity gets too low", tags=["hvac"])
@when("Member of MinIndoorHumidity changed")
def low_hum(event):
    if isinstance(event.itemState, UnDefType): return

    alerted = get_key_value(event.itemName, "Alert", "alerted") or "OFF"
    name = get_name(event.itemName)
    low = QuantityType(u"30")
    if alerted == "OFF" and event.itemState < low:
        send_alert("Remember to fill the humidifiers, {} is {}%".format(name, event.itemState), low_hum.log)
        set_metadata(event.itemName, "Alert", {"alerted": 'ON'}, overwrite=False)
    elif alerted == "ON" and event.itemState >= DecimalType(31):
        set_metadata(event.itemName, "Alert", {"alerted": "OFF"}, overwrite=False)

@rule("MBR Humidifier", description="Turn on the master bedroom humidifier when necessary", tags=["hvac"])
@when("Item vMBR_Humidity changed")
@when("Item vMBR_Target_Humidity changed")
def mbr_hum(event):

    # Default to 30% if target isn't set
    if isinstance(items["vMBR_Target_Humidity"], UnDefType):
        events.postUpdate("bMBR_Taret_Humidity", "30%")
        return # Rule will be retriggered

    # Default to OFF if there is no humidity reading
    if isinstance(items["vMBR_Humidity"], UnDefType):
        mbr_hum.log.warning("Cannot adjust humidity in MBR, current reading is {}".format(items["vMBR_Humidity"]))
        events.sendCommand("aMBR_Humidifier", "OFF")
        return

    # Determine the humidifer state
    new_state = "Stay"
    curr     = items["vMBR_Humidity"]
    tgt      = items["vMBR_Target_Humidity"]
    if curr.compareTo(tgt) < 0: new_state = "ON"
    elif curr.compareTo(tgt.add(QuantityType(u"2 %"))) > 0: new_state = "OFF"

    # Update the fan
    if new_state != "Stay": sendCommandCheckFirst("aMBR_Humidifier", new_state)

We use the same approach with the metadata to determine if we have sent an alert. Also notice the use of QuantityType as the humidity Items are Number:Dimensionless.

Charting

I use Grafana for my charts but I’ve had a devil of a time making Grafana work right on my sitemap and inside the Android app through myopenhab.org and sometimes directly. One alternative is to generate static images for the charts but doing so is a very expensive operation and if you put the Charts on your sitemap and use the visibility tag to only show the one you care about, it will still be rendering the hidden ones causing a huge spike in CPU.

I wrote the below Rules to generate the minimum number of chart images based on the chart period one is actively looking at.

Rules DSL

rule "Change the chart polling period"
when
      Item ChartVisibility changed or
      System started
then

      val period = if(ChartVisibility.state.toString == "NULL" || ChartVisibility.state.toString == "UNDEF") "h" else ChartVisibility.state.toString

      // Cancel any running Timers
      ChartPolls.members.filter[ c | c.state == ON ].forEach[ c | c.postUpdate(OFF) ]

      // Start kick off a new pull of the charts
      sendCommand("ChartPoll_"+period, "OFF")
end

rule "Pull charts from Grafana"
when
    Member of ChartPolls received command OFF
then
      logDebug(logName, "Getting chart images...")

      val argTemplate = "/usr/bin/wget 'http://10.10.1.127:3000/render/d-solo/000000001/home-automation?orgId=1&from=%FROM%&to=now&panelId=%PID%&width=1000&height=500&tz=America%2FDenver&timeout=10000' -O /openhab/conf/html/%OUT%"

      val Map<String, String> panels = newHashMap("1" -> "temp",
                                                  "2" -> "hum",
                                                  "8" -> "light",
                                                  "4" -> "power")
      val period = if(ChartVisibility.state.toString == "NULL" || ChartVisibility.state.toString == "UNDEF") "h" else ChartVisibility.state.toString

      panels.keySet().forEach[ pid |
              val String template = argTemplate.replace("%PID%", pid).replace("%OUT%", panels.get(pid)+".jpg")
              val String fr       = if( period == "NULL" || period == "UNDEF" ) "now-1h" else "now-1"+period
              val String results  = executeCommandLine(template.replace("%FROM%", fr), 5000)
              logDebug(logName, "Results from wget for " + pid + " and period " + period + "\n" + results)
      ]

      // Reschedule the timer
      sendCommand("ChartPoll_"+period, "ON")
      logDebug(logName, "Done getting chart images")
end

The theory of operation is fully explained in Grafana Image Charts.

Python

from core.rules import rule
from core.triggers import when
from core.actions import ScriptExecution
from org.joda.time import DateTime
from configuration import grafanaHost
from core.actions import Exec
import subprocess
import traceback

timer = None

# Mapping between the chart time periood and how often to poll for the chart
polling = { "h": 60,    # one minute
            "d": 300,   # five minutes
            "w": 900,   # 15 minutes
            "M": 3600,  # one hour
            "y": 86400} # one day

# Mapping between Grafana panel ID and file names for the chart
panals = { "1": "temp",
           "2": "hum",
           "8": "light",
           "4": "power"}

def pull_charts(log, period, poll_time):

   # Get the image for each chart
   for pid, fname in panals.items():
       fr = "now-1{}".format(period)
       results = subprocess.check_output(['/usr/bin/wget', 'http://{0}/render/d-solo/000000001/home-automation?orgId=1&from={1}&to=now&panelId={2}&width=1000&height=500&timeout=10000'.format(grafanaHost, fr, pid), '-O', '/openhab/conf/html/{0}.jpg'.format(fname)])
       log.debug("Results from wget for {} and period {}\n{}".format(pid, period, results))

   # Reschedule the timer
   if period != str(items["ChartVisibility"]):
       log.warning("Chart polling period changed but timer is still running, not rescheduling")
       return
   else:
       global timer
       timer = ScriptExecution.createTimer(DateTime.now().plusSeconds(poll_time), lambda: pull_charts(log, period, poll_time))

@rule("Chart polling period", description="Change the polling period to generate new charts.", tags=["admin"])
@when("Item ChartVisibility changed")
@when("System started")
def chart_poll(event):
    chart_poll.log.info("Kicking off/modifying chart polling")

    period = "h" if isinstance(items["ChartVisibility"], UnDefType) else str(items["ChartVisibility"])

    # Cancel the timer if there is one
    global timer
    if timer is not None and not timer.hasTerminated(): timer.cancel()

    # Start a looping timer to pull new charts
    pull_charts(chart_poll.log, period, polling[period])

Things to notice:

  • Notice how the looping Timer works slightly different. We can call the pull_charts function and it schedules/reschedules itself. The Rule just calls the function and then it’s done.

  • Pay attention to when you need to promote a variable to global, which is mainly if you have a global variable like timer that you need to modify.

  • I never could get executeCommandLine to work, it never passed the arguments to wget correctly so you can see here an example of using subprocess instead of the OH Action.

Fan Control

As I’ve discussed elsewhere, I do not have an air conditioner but I do have a basement and the forced air heater is in the basement. So I turn on the heater’s fan to take the colder air in the basement and pump it to the upper floors of the house. This Rule does the magic.

rule "Turn on the fan when necessary"
when
    Member of gIndoorTemps received update
then
    // TODO, treat each temp sensor as it's own thermostat and control the fan and heat
    // based on min and max thresholds for each

    // Wait at least five minutes before changing the fan state after the last change
    if(vNest_Fan_LastChange.state != NULL && now.minusMinutes(5).isBefore(new DateTime(vNest_Fan_LastChange.state.toString))) return;

    // Get the target and upper and lower temps
    val target = (aNest_TargetTemp.state as QuantityType<Number>).floatValue
    val lower = (LowerFloorsTemps.state as QuantityType<Number>).floatValue
    val upper = (UpperFloorsTemps.state as QuantityType<Number>).floatValue

    // Determine if the fan should be on or off: upper is higher than lower and the target and it is warm outside
    // 1 degree as a buffer
    val diff = upper - lower
    var fanState = "Stay"
    if(diff >= 0 && upper > target && vWeather_Temp.state > 60|°F) fanState = "ON"
    else if(upper < target - 1) fanState = "OFF"

    // Change the fan state
    if(fanState != "Stay" && aNest_Fan.state.toString != fanState) {
        logInfo(logName, "Setting the house fan to " + fanState)
        aNest_Fan.sendCommand(fanState)
        vNest_Fan_LastChange.postUpdate(new DateTimeType)
    }
end

Python

from core.rules import rule
from core.triggers import when
from org.joda.time import DateTime
from core.utils import sendCommandCheckFirst

# Constants used to calcualte whether to turn on the fan
warm_temp = QuantityType(u"60 °F")
zero      = QuantityType(u"0 °F")
one       = QuantityType(u"1 °F")

@rule("Fan timestamp", description="Update the fan's last change timestamp", tags=["hvac"])
@when("Item aNest_Fan changed")
def fan_time(event):
    fan_time.log.info("The house fan turned {}".format(str(event.itemState).lower()))
    if isinstance(event.itemState, UnDefType): events.postUpdate("vNest_Fan_LastChange", str(DateTime.now()))

@rule("Fan control", description="Turn on the fan when necessary", tags=["hvac"])
@when("Member of gIndoorTemps received update")
def fan_ctrl(event):
    # TODO, treate each temp sensor as it's own thermostat and control the fan and heat
    # based on min and max thresholds for each

    # Wait at least five minutes before changing the fan state after the last change
    last_change = items["vNest_Fan_LastChange"]
    if not isinstance(last_change, UnDefType) and DateTime.now().minusMinutes(5).isBefore(DateTime(str(last_change))): return

    # Get the target and upper and lower temps, all are QuantityTypes
    # upper is the maximum of the top floor and main flor temps
    # lower is the minimum of the main floor and basement temps
    target    = items["aNest_TargetTemp"]
    upper     = items["UpperFloorsTemps"]
    delta     = upper.subtract(items["LowerFloorsTemps"])
    outside   = items["vWeather_Temp"]

    # Determine if the fan should be on or off: upper is higher than lower and the target and it's warm outside
    fan_state = "Stay"
    if delta > zero and upper > target and outside > warm_temp: fan_state = "ON"
    elif upper < (target.subtract(one)): fan_state = "OFF"

    # Change the fan state
    if fan_state != "Stay": sendCommandCheckFirst("aNest_Fan", fan_state)

Notice a few more cases of use of QuantityType. I use a DateTime Item which gets restoredOnStartup to make sure we don’t change the state of the fan too rapidly. Rather than just relying upon hysteresis I also have a five minute timer: the fan must have been in it’s current state at least five minutes before it can change state.

Much of the logic in this Rule is handled by Groups aggregation functions to keep track of the minimum and maximum temperatures across the three floors. I have two sensors on the top floor, one on the main floor and one in the basement. LowerFloorsTemp is the minimum between the basement and main floor temps. UpperFloorsTemp is the maximum of the main floor and top floor sensors.

If there is a difference between the two, the upper temp is greater than the target temp, and it’s warm outside (I don’t want to monkey with the fan in the winder when the heater runs) we turn the fan on.

NOTE: Yes I know sendCommandCheckFirst has changed names in the latest helper libraries. I’ve not upgraded since I started this migration to minimize changing variables.

Time of Day

See Design Pattern: Time Of Day.

Rules DSL

rule "Calculate time of day state"
when
  System started or
  Channel 'astro:sun:local:rise#event'    triggered START or
  Channel 'astro:sun:local:set#event'     triggered START or
  Channel 'astro:sun:set120:set#event'    triggered START or
  Time cron "0 1 0 * * ? *" or
  Time cron "0 0 6 * * ? *" or
  Time cron "0 0 23 * * ? *"
then

  logInfo(logName, "Calculating time of day...")

  // Calculate the times for the static tods and populate the associated Items
  // Update when changing static times
  // Jump to tomorrow and subtract to avoid problems at the chang over to/from DST
  val morning_start = now.withTimeAtStartOfDay.plusDays(1).minusHours(18)
  vMorning_Time.postUpdate(morning_start.toString)

  val night_start = now.withTimeAtStartOfDay.plusDays(1).minusHours(1)
  vNight_Time.postUpdate(night_start.toString)

  val bed_start = now.withTimeAtStartOfDay
  vBed_Time.postUpdate(bed_start.toString)

  // Convert the Astro Items to Joda DateTime
  val day_start = new DateTime(vSunrise_Time.state.toString)
  val evening_start = new DateTime(vSunset_Time.state.toString)
  val afternoon_start = new DateTime(vEvening_Time.state.toString)

  // Calculate the current time of day
  var curr = "UNKNOWN"
  switch now {
      case now.isAfter(morning_start)   && now.isBefore(day_start):       curr = "MORNING"
      case now.isAfter(day_start)       && now.isBefore(afternoon_start): curr = "DAY"
      case now.isAfter(afternoon_start) && now.isBefore(evening_start):   curr = "AFTERNOON"
      case now.isAfter(evening_start)   && now.isBefore(night_start):     curr = "EVENING"
      case now.isAfter(night_start):                                      curr = "NIGHT"
      case now.isAfter(bed_start)       && now.isBefore(morning_start):   curr = "BED"
  }

  logInfo(logName, "Calculated time of day is " + curr)
  vTimeOfDay.sendCommand(curr)

end

Python

from core.rules import rule
from core.triggers import when
from org.joda.time import DateTime
from core.utils import sendCommandCheckFirst, postUpdateCheckFirst

@rule("Time of Day", description="State machine driven by time and solar events", tags=["weather"])
@when("System started")
@when("Time cron 0 1 0 * * ? *")                              # BED
@when("Time cron 0 0 6 * * ? *")                              # MORNING
@when("Channel 'astro:sun:local:rise#event' triggered START") # DAY
@when("Channel 'astro:sun:set120:set#event' triggered START") # AFTERNOON
@when("Channel 'astro:sun:local:set#event' triggered START")  # EVENING
@when("Time cron 0 0 23 * * ? *")                             # NIGHT
def tod(event):

    # 1. Always run the Rule

    tod.log.info("Calculating tme of day...")

    # 2. Calculate what needs to be done
    now = DateTime.now()

    # Convert the static and Astro times to a DateTime for comparisons
    morning_start = now.withTime(6, 0, 0, 0)
    day_start = DateTime(str(items["vSunrise_Time"]))
    afternoon_start = DateTime(str(items["vEvening_Time"]))
    evening_start = DateTime(str(items["vSunset_Time"]))
    night_start = now.withTime(23, 0, 0, 0)
    bed_start = now.withTime(0, 1, 0, 0)

    # Calculate the current time of day
    curr = "UNKNOWN"
    if   now.isAfter(morning_start)   and now.isBefore(day_start):       curr = "MORNING"
    elif now.isAfter(day_start)       and now.isBefore(afternoon_start): curr = "DAY"
    elif now.isAfter(afternoon_start) and now.isBefore(evening_start):   curr = "AFTERNOON"
    elif now.isAfter(evening_start)   and now.isBefore(night_start):     curr = "EVENING"
    elif now.isAfter(night_start):                                       curr = "NIGHT"
    elif now.isAfter(bed_start)       and now.isBefore(morning_start):   curr = "BED"

    # 3. Do it

    # Command and update if there is a new state
    tod.log.info("Calculated time of day is {}.".format(curr))

    postUpdateCheckFirst("vMorning_Time", str(morning_start))
    postUpdateCheckFirst("vNight_Time", str(night_start))
    postUpdateCheckFirst("vBed_Time", str(bed_start))
    sendCommandCheckFirst("vTimeOfDay", curr)

This is a pretty faithful translation of the Rules DSL version. But stay tuned, I have an idea to totally rework this Rule to make it more suitable as a library or template Rule: hint, dicts and Timers.

There is a slight reordering of operations. I moved the updates and commands to the end of the Rule.

Copy the conditions icon

I use the OpenWeatherMap binding for weather right now and it has a Channel to get the path to the current conditions icon. But I want to use this icon as an icon on the sitemap instead of as an Image. So the following Rule will trigger when the weather conditions change, download the icon file and convert if from a gif to a png so BasicUI can use it.

Rules DSL

import javax.imageio.ImageIO
import java.io.File

rule "Copy the conditions icon"
when
  Item vWeather_Conditions_Icon changed
then
    val file = "/openhab/conf/icons/classic/weather.gif"
    logInfo(logName, "Attempting to write new weather conditions icon: " + vWeather_Conditions_Icon.state.toString)
    executeCommandLine("/usr/bin/wget -q -O " + file + " " + vWeather_Conditions_Icon.state.toString, 5000)

    val input = new File(file)
    val output = new File(file.replace("gif", "png"))
    ImageIO::write( ImageIO::read(input), "png", output)

    executeCommandLine("rm " + file)
end

Python

from core.rules import rule
from core.triggers import when
from configuration import weather_icon_path
import subprocess
from javax.imageio import ImageIO
from java.io import File

@rule("Weather Icon", description="Copy the current weather conditions icon", tags=["weather"])
@when("Item vWeather_Conditions_Icon changed")
def cond_icon(event):
    results = subprocess.check_output(['/usr/bin/wget', '-q', '-O', weather_icon_path, str(items["vWeather_Conditions_Icon"])])

    input_file  = File(weather_icon_path)
    output_file = File(weather_icon_path.replace('gif', 'png'))
    ImageIO.write( ImageIO.read(input_file), 'png', output_file)

    subprocess.check_output(['rm', weather_icon_path])

Once again, instead of using executeCommandLine I use subprocess to call wget. I thought I was in trouble for converting the image file formats because I thought I’d have to figure how to install and use the PIL, but this is JSR223! We can use Java classes too! :smiley:

I moved the path to the icon file to my configuration file.

Thoughts

I’ve only a few more Rules to convert but they add nothing new so I’m not going to post them. They will not require anything new that hasn’t already been done in the previous examples. However, these postings are not themselves done! I have at least one more posting where I will break down the Time of Day Rule and completely rework that Rule so it works in a way that is simply not possible in Rules DSL, and perhaps get my first contribution to the community library in the process. :wink:

Previous: Journey to JSR223 Python 7 of 9
Next: Journey to JSR223 Python 9 of 9

3 Likes