#!/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(("\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.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 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 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("> {} = {}".format(topic, val)) mq.publish(topic, val, qos=1, retain=True) def send(self, key, val): global mq topic = "burrow/" + key debug("> {} := {}".format(topic, val)) mq.publish(topic, val, qos=1, retain=False) 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 auto_loft_fan(): global st 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, 25, 26) > 0: if st.hysteresis('lf_loft_hotter_than_out', lt, out - 1, out + 1) > 0: fs = 3 else: fs = 1 else: if st.min in range(10, 15): fs = 2 else: fs = 0 st.set("loft/fan", fs) def auto_circ(): global st 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 if o == "--debug": debug_mode = True mq = mqtt.Client() mq.on_connect = on_connect mq.on_message = on_message 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() 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)