#!/usr/bin/python3
+from collections import deque
import getopt
+import os
import paho.mqtt.client as mqtt
import sys
import time
debug_mode = False
+log_name = '/run/burrow-auto'
+log_file = None
indent = 0
def debug(msg):
if debug_mode:
- print(("\t" * indent) + msg)
+ print(("\t" * indent) + msg, file=sys.stderr)
+ elif log_file is not None:
+ print(("\t" * indent) + msg, file=log_file)
+
+def debug_open():
+ if not debug_mode:
+ global log_file
+ log_file = open(log_name + '.new', 'w')
+
+def debug_close():
+ if debug_mode:
+ debug("=" * 80)
+ else:
+ global log_file
+ log_file.close()
+ log_file = None
+ os.rename(log_name + '.new', log_name)
def diff(x, y):
if x is None or y is None:
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 True
def hysteresis(self, key, value, low, high):
- if key in self.hyst_state:
- old_state = self.hyst_state[key]
- else:
- old_state = 0
+ old_state = self.hyst_state.get(key, 0)
if value is None:
new_state = 0
elif old_state <= 0:
def update_average(self, key, window_seconds):
if key not in self.running_averages:
- self.running_averages[key] = ([], 0, 0, None)
+ 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.pop(0)
+ history.popleft()
curr = self.get_sensor(key)
history.append((self.now, curr))
avg = None
self.running_averages[key] = (history, sum, count, avg)
- debug("= avg {:.6} ({} samples, {} non-null)".format(avg, len(history), count))
- if avg is not None:
+ 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]
- debug("< {} = avg {:.6}".format(key, val))
+ if val is None:
+ debug("< {} = avg NONE".format(key))
+ else:
+ debug("< {} = avg {:.6}".format(key, val))
return val
st = State()
-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')
def auto_loft_fan():
global st
- lt = st.get_sensor("temp/loft")
- lt_high = st.hysteresis('lt_high', lt, 29, 30)
- lt_mid = st.hysteresis('lt_mid', lt, 24, 25)
- if lt_high > 0:
+ 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
- elif lt_mid > 0:
- if st.hour in range(10, 20):
- fs = 3
else:
fs = 1
else:
- if st.hour in range(8, 22):
- if st.min % 30 in range(0, 5):
- fs = 3
- else:
- fs = 0
+ if st.min in range(10, 15):
+ fs = 2
else:
fs = 0
- # FIXME: Disabled for now
- fs = 0
+
st.set("loft/fan", fs)
+
def auto_circ():
global st
if st.hour in range(7, 24):
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')
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)
+ ac_on = ac_is_on()
# XXX: Temporarily disabled
- house_warm = -1
- house_hot = -1
- ac_off = 1
+ #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)
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):
+ 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_off={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_off, outside_warmer, mixed_warmer))
+ debug("Air: house_warm={} house_hot={} ac_on={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_on, outside_warmer, mixed_warmer))
+
+ if ac_on != 0:
+ st.set("air/ac-on", "{} {}".format(1 if ac_on > 0 else 0, int(st.now)))
+ else:
+ st.set("air/ac-on", "")
+
+
+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:
mq.on_connect = on_connect
mq.on_message = on_message
mq.will_set("status/auto", "dead", retain=True)
-mq.connect("127.0.0.1")
+mq.connect("burrow-mqtt")
mq.publish("status/auto", "ok", retain=True)
mq.loop_start()
checks = [
('loft-fan', auto_loft_fan),
('circ', auto_circ),
- ('air', auto_air)
+ ('air', auto_air),
+ ('aircon', auto_aircon),
]
while True:
+ debug_open()
st.update()
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)
+ 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:
indent -= 1
else:
debug("{} DISABLED".format(name))
- debug("=" * 80)
+
+ debug_close()
time.sleep(10)