-#!/usr/bin/python
+#!/usr/bin/python3
import getopt
import paho.mqtt.client as mqtt
import time
debug_mode = False
+indent = 0
def debug(msg):
if debug_mode:
- print msg
+ print(("\t" * indent) + msg)
+
+def diff(x, y):
+ if x is None or y is None:
+ return None
+ else:
+ return x - y
class State:
def __init__(self):
- self.attrs = {}
+ self.attrs = {}
+ self.hyst_state = {}
+ self.running_averages = {}
def update(self):
- self.now = time.time()
- tm = time.localtime(self.now)
- self.year = tm.tm_year
- self.month = tm.tm_mon
- self.day = tm.tm_mday
- self.hour = tm.tm_hour
- self.min = tm.tm_min
- self.wday = tm.tm_wday
+ self.now = time.time()
+ tm = time.localtime(self.now)
+ self.year = tm.tm_year
+ self.month = tm.tm_mon
+ self.day = tm.tm_mday
+ self.hour = tm.tm_hour
+ self.min = tm.tm_min
+ self.wday = tm.tm_wday
+ debug("[{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d} wday={}]".format(
+ self.year, self.month, self.day,
+ self.hour, self.min, tm.tm_sec,
+ self.wday
+ ))
def get_sensor(self, key):
topic = "burrow/" + key
- if topic in self.attrs:
- s = self.attrs[topic].split(" ")
- if len(s) >= 2 and s[1] < self.now - 120:
- return None
- return float(s[0])
- else:
- return None
+ if topic in self.attrs:
+ s = self.attrs[topic].split(" ")
+ if len(s) >= 2 and int(s[1]) < self.now - 120:
+ debug("< {} EXPIRED".format(key))
+ return None
+ else:
+ debug("< {} = {}".format(key, s[0]))
+ return float(s[0])
+ else:
+ debug("< {} UNDEFINED".format(key))
+ return None
def set(self, key, val):
global mq
topic = "burrow/" + key
- debug("Setting {} to {}".format(topic, val))
- mq.publish(topic, val, qos=1, retain=True)
+ debug("> {} = {}".format(topic, val))
+ mq.publish(topic, val, qos=1, retain=True)
+
+ def auto_enabled(self, key):
+ topic = "burrow/auto/" + key
+ if topic in self.attrs:
+ return self.attrs[topic] != '0'
+ else:
+ return True
+
+ def hysteresis(self, key, value, low, high):
+ if key in self.hyst_state:
+ old_state = self.hyst_state[key]
+ else:
+ old_state = 0
+ if value is None:
+ new_state = 0
+ elif old_state <= 0:
+ if value >= high:
+ new_state = 1
+ else:
+ new_state = -1
+ else:
+ if value <= low:
+ new_state = -1
+ else:
+ new_state = 1
+ self.hyst_state[key] = new_state
+ return new_state
+
+ def update_average(self, key, window_seconds):
+ if key not in self.running_averages:
+ self.running_averages[key] = ([], 0, 0, None)
+ (history, sum, count, avg) = self.running_averages[key]
+
+ while len(history) > 0 and history[0][0] <= self.now - window_seconds:
+ if history[0][1] is not None:
+ sum -= history[0][1]
+ count -= 1
+ history.pop(0)
+
+ curr = self.get_sensor(key)
+ history.append((self.now, curr))
+ if curr is not None:
+ sum += curr
+ count += 1
+
+ if count > len(history) // 2:
+ avg = sum / count
+ else:
+ avg = None
+
+ self.running_averages[key] = (history, sum, count, avg)
+ if avg is None:
+ debug("= avg NONE ({} samples, {} non-null)".format(len(history), count))
+ else:
+ debug("= avg {:.6} ({} samples, {} non-null)".format(avg, len(history), count))
+ self.set("avg/" + key, "{:.6} {}".format(avg, int(self.now)))
+
+ def get_sensor_avg(self, key):
+ val = self.running_averages[key][3]
+ if val is None:
+ debug("< {} = avg NONE".format(key))
+ else:
+ debug("< {} = avg {:.6}".format(key, val))
+ return val
st = State()
def on_message(mq, userdata, msg):
global st
- debug("Message {}: {}".format(msg.topic, msg.payload))
- st.attrs[msg.topic] = msg.payload
+ # debug("Message {}: {}".format(msg.topic, msg.payload))
+ st.attrs[msg.topic] = msg.payload.decode('utf-8')
def auto_loft_fan():
global st
lt = st.get_sensor("temp/loft")
- if lt is not None and lt >= 30:
- fs = 3
- elif st.hour in range(10, 20):
- fs = 2
+ lt_high = st.hysteresis('lt_high', lt, 29, 30)
+ lt_mid = st.hysteresis('lt_mid', lt, 24, 25)
+ if lt_high > 0:
+ fs = 3
+ elif lt_mid > 0:
+ if st.hour in range(10, 20):
+ fs = 3
+ else:
+ fs = 1
else:
- fs = 1
+ if st.hour in range(8, 22):
+ if st.min % 30 in range(0, 5):
+ fs = 3
+ else:
+ fs = 0
+ else:
+ fs = 0
+ # FIXME: Disabled for now
+ fs = 0
st.set("loft/fan", fs)
def auto_circ():
global st
- if st.hour in range(19, 23):
+ if st.hour in range(7, 24):
c = 1
else:
c = 0;
st.set("loft/circulation", c)
+def auto_air():
+ global st
+ tii = st.get_sensor_avg('air/inside-intake')
+ tie = st.get_sensor_avg('air/inside-exhaust')
+ toi = st.get_sensor_avg('air/outside-intake')
+ tmix = st.get_sensor_avg('air/mixed')
+ house_warm = st.hysteresis('house_warm', tii, 23.5, 24.5)
+ house_hot = st.hysteresis('house_hot', tii, 24.5, 25)
+
+ # Is AC currently on (mixed air is significantly colder than inside exhaust)?
+ if tie is None or tmix is None:
+ ac_off = 1
+ else:
+ ac_off = st.hysteresis('ac_off', tmix, tie - 5, tie - 4)
+
+ # XXX: Temporarily disabled
+ house_warm = -1
+ house_hot = -1
+ ac_off = 1
+
+ # Do we want to bypass the heat exchanger?
+ outside_warmer = st.hysteresis('outside_warmer', diff(toi, tii), -0.5, 0.5)
+ if (house_warm > 0) and (outside_warmer > 0) or \
+ (house_warm < 0) and (outside_warmer < 0):
+ st.set('air/bypass', 0)
+ else:
+ st.set('air/bypass', 1)
+
+ # Is mixed air colder than air from the inside?
+ mixed_warmer = st.hysteresis('mixed_warmer', diff(tmix, tii), -1, 0)
+
+ # Do we want to boost heat exchanger fan?
+ if ac_off < 0 or (house_hot > 0 and mixed_warmer < 0):
+ st.set('air/exchanger-fan', 255)
+ else:
+ st.set('air/exchanger-fan', 0)
+
+ debug("Air: house_warm={} house_hot={} ac_off={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_off, outside_warmer, mixed_warmer))
+
opts, args = getopt.gnu_getopt(sys.argv[1:], "", ["debug"])
for opt in opts:
o, arg = opt
mq = mqtt.Client()
mq.on_connect = on_connect
mq.on_message = on_message
-mq.will_set("status/auto", "failed", retain=True)
-mq.connect("127.0.0.1")
+mq.will_set("status/auto", "dead", retain=True)
+mq.connect("burrow-mqtt")
mq.publish("status/auto", "ok", retain=True)
mq.loop_start()
+# Heuristic delay to get all attributes from MQTT
+time.sleep(3)
+
+checks = [
+ ('loft-fan', auto_loft_fan),
+ ('circ', auto_circ),
+ ('air', auto_air)
+]
+
while True:
st.update()
- auto_loft_fan()
- auto_circ()
+
+ debug("averages")
+ indent += 1
+ st.update_average('air/outside-intake', 60)
+ st.update_average('air/inside-intake', 60)
+ st.update_average('air/inside-exhaust', 60)
+ st.update_average('air/mixed', 60)
+ indent -= 1
+
+ for name, func in checks:
+ if st.auto_enabled(name):
+ debug(name)
+ indent += 1
+ func()
+ indent -= 1
+ else:
+ debug("{} DISABLED".format(name))
+ debug("=" * 80)
time.sleep(10)