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!
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.
Previous: Journey to JSR223 Python 7 of 9
Next: Journey to JSR223 Python 9 of 9