X-Git-Url: http://mj.ucw.cz/gitweb/?a=blobdiff_plain;ds=inline;f=auto%2Fburrow-auto;h=dbcd822e1fea00d35b25dc27c5c834c96f365064;hb=38b7ba69dd4b5d0bc8f9c8b4468f2180e319cb51;hp=b7eb527fccab2f6f773ae750c26f5b1b1df5dc41;hpb=64df32d112a2e12809067d54627a116f68bc6413;p=home-hw.git diff --git a/auto/burrow-auto b/auto/burrow-auto index b7eb527..dbcd822 100755 --- a/auto/burrow-auto +++ b/auto/burrow-auto @@ -1,75 +1,259 @@ -#!/usr/bin/python +#!/usr/bin/python3 +from collections import deque import getopt import paho.mqtt.client as mqtt import sys import time debug_mode = False +indent = 0 def debug(msg): if debug_mode: - print msg + print(("\t" * indent) + msg, file=sys.stderr) + +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) -st = State() + def send(self, key, val): + global mq + topic = "burrow/" + key + debug("> {} := {}".format(topic, val)) + mq.publish(topic, val, qos=1, retain=False) -def on_connect(mq, userdata, flags, rc): - mq.subscribe("burrow/#") + 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): + old_state = self.hyst_state.get(key, 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] = (deque(), 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.popleft() + + 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 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 - else: - fs = 1 + lt = st.get_sensor_avg("temp/loft") + out = st.get_sensor_avg('air/outside-intake') + + if lt is None or out is None: + fs = 0 + elif st.hysteresis('lf_out_cold', out, 5, 6) < 0: + fs = 0 + elif st.hysteresis('lf_out_cool', out, 14, 15) < 0: + if st.min in range(10, 15): + fs = 2 + else: + fs = 0 + elif ac_is_on() > 0: + fs = 3 + elif st.hysteresis('lf_loft_hot', lt, 23, 24) > 0: + if st.hysteresis('lf_loft_hotter_than_out', lt, out - 1, out + 1) > 0: + fs = 3 + else: + fs = 1 + 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 ac_is_on(): + # Heuristics to tell if AC is currently on + + tie = st.get_sensor_avg('air/inside-exhaust') + tmix = st.get_sensor_avg('air/mixed') + if tie is None or tmix is None: + return 0 + + # Mixed air is significantly colder than inside exhaust + ac_on = -st.hysteresis('ac_off', tmix, tie - 5, tie - 4) + + # FIXME: It might also mean that the loft is cold + + return ac_on + + +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) + ac_on = ac_is_on() + + # XXX: Temporarily disabled + #house_warm = -1 + #house_hot = -1 + #ac_on = -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_on > 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_on={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_on, outside_warmer, mixed_warmer)) + + +def auto_aircon(): + global st + tii = st.get_sensor_avg('air/inside-intake') + tie = st.get_sensor_avg('air/inside-exhaust') + house_hot = st.hysteresis('ac_house_hot', tii, 23.5, 24) + outside_hot = st.hysteresis('ac_outside_hot', tie, 24, 25) + ac_on = ac_is_on() + + if house_hot > 0 and outside_hot > 0: + want_ac = 1 + else: + want_ac = -1 + + if not hasattr(st, 'last_ac_change'): + st.last_ac_change = st.now + need_wait = st.last_ac_change + 300 - st.now # FIXME: Increase + + if need_wait > 0: + action = f"wait({need_wait:.0f})" + elif ac_on == 0: + action = "need-data" + elif ac_on != want_ac: + action = "change" + st.send('air/aircon-remote', 'p') + st.last_ac_change = st.now + else: + action = "none" + + debug("AC: house_hot={} outside_hot={} ac_on={} want_ac={} action={}".format(house_hot, outside_hot, ac_on, want_ac, action)) + + +def on_connect(mq, userdata, flags, rc): + mq.subscribe("burrow/#") + +def on_message(mq, userdata, msg): + global st + # debug("Message {}: {}".format(msg.topic, msg.payload)) + st.attrs[msg.topic] = msg.payload.decode('utf-8') + opts, args = getopt.gnu_getopt(sys.argv[1:], "", ["debug"]) for opt in opts: o, arg = opt @@ -79,13 +263,40 @@ for opt in opts: 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), + ('aircon', auto_aircon), +] + while True: st.update() - auto_loft_fan() - auto_circ() + + debug("averages") + indent += 1 + st.update_average('air/outside-intake', 180) + st.update_average('air/inside-intake', 180) + st.update_average('air/inside-exhaust', 180) + st.update_average('air/mixed', 180) + st.update_average('temp/loft', 180) + 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)