3 from collections import deque
6 import paho.mqtt.client as mqtt
11 log_name = '/run/burrow-auto'
17 print(("\t" * indent) + msg, file=sys.stderr)
18 elif log_file is not None:
19 print(("\t" * indent) + msg, file=log_file)
24 log_file = open(log_name + '.new', 'w')
33 os.rename(log_name + '.new', log_name)
36 if x is None or y is None:
45 self.running_averages = {}
48 self.now = time.time()
49 tm = time.localtime(self.now)
50 self.year = tm.tm_year
51 self.month = tm.tm_mon
53 self.hour = tm.tm_hour
55 self.wday = tm.tm_wday
56 debug("[{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d} wday={}]".format(
57 self.year, self.month, self.day,
58 self.hour, self.min, tm.tm_sec,
62 def get_sensor(self, key):
63 topic = "burrow/" + key
64 if topic in self.attrs:
65 s = self.attrs[topic].split(" ")
66 if len(s) >= 2 and int(s[1]) < self.now - 120:
67 debug("< {} EXPIRED".format(key))
70 debug("< {} = {}".format(key, s[0]))
73 debug("< {} UNDEFINED".format(key))
76 def set(self, key, val):
78 topic = "burrow/" + key
79 debug("> {} = {}".format(topic, val))
80 mq.publish(topic, val, qos=1, retain=True)
82 def send(self, key, val):
84 topic = "burrow/" + key
85 debug("> {} := {}".format(topic, val))
86 mq.publish(topic, val, qos=1, retain=False)
88 def auto_enabled(self, key):
89 topic = "burrow/auto/" + key
90 if topic in self.attrs:
91 return self.attrs[topic] != '0'
95 def hysteresis(self, key, value, low, high):
96 old_state = self.hyst_state.get(key, 0)
109 self.hyst_state[key] = new_state
112 def update_average(self, key, window_seconds):
113 if key not in self.running_averages:
114 self.running_averages[key] = (deque(), 0, 0, None)
115 (history, sum, count, avg) = self.running_averages[key]
117 while len(history) > 0 and history[0][0] <= self.now - window_seconds:
118 if history[0][1] is not None:
123 curr = self.get_sensor(key)
124 history.append((self.now, curr))
129 if count > len(history) // 2:
134 self.running_averages[key] = (history, sum, count, avg)
136 debug("= avg NONE ({} samples, {} non-null)".format(len(history), count))
138 debug("= avg {:.6} ({} samples, {} non-null)".format(avg, len(history), count))
139 self.set("avg/" + key, "{:.6} {}".format(avg, int(self.now)))
141 def get_sensor_avg(self, key):
142 val = self.running_averages[key][3]
144 debug("< {} = avg NONE".format(key))
146 debug("< {} = avg {:.6}".format(key, val))
154 lt = st.get_sensor_avg("temp/loft")
155 out = st.get_sensor_avg('air/outside-intake')
157 if False and st.hour in range(21, 24) or st.hour in range(6, 10):
159 elif lt is None or out is None:
161 elif st.hysteresis('lf_out_cold', out, 5, 6) < 0:
163 elif st.hysteresis('lf_out_cool', out, 14, 15) < 0:
164 if st.min in range(10, 15):
170 elif st.hysteresis('lf_loft_hot', lt, 25, 26) > 0:
171 if st.hysteresis('lf_loft_hotter_than_out', lt, out - 1, out + 1) > 0:
176 if st.min in range(10, 15):
181 st.set("loft/fan", fs)
186 if st.hour in range(7, 24):
190 st.set("loft/circulation", c)
194 # Heuristics to tell if AC is currently on
196 tie = st.get_sensor_avg('air/inside-exhaust')
197 tmix = st.get_sensor_avg('air/mixed')
198 if tie is None or tmix is None:
201 # Mixed air is significantly colder than inside exhaust
202 ac_on = -st.hysteresis('ac_off', tmix, tie - 5, tie - 4)
204 # FIXME: It might also mean that the loft is cold
211 tii = st.get_sensor_avg('air/inside-intake')
212 tie = st.get_sensor_avg('air/inside-exhaust')
213 toi = st.get_sensor_avg('air/outside-intake')
214 tmix = st.get_sensor_avg('air/mixed')
215 house_warm = st.hysteresis('house_warm', tii, 23.5, 24.5)
216 house_hot = st.hysteresis('house_hot', tii, 24.5, 25)
219 # XXX: Temporarily disabled
224 # Do we want to bypass the heat exchanger?
225 outside_warmer = st.hysteresis('outside_warmer', diff(toi, tii), -0.5, 0.5)
226 if (house_warm > 0) and (outside_warmer > 0) or \
227 (house_warm < 0) and (outside_warmer < 0):
228 st.set('air/bypass', 0)
230 st.set('air/bypass', 1)
232 # Is mixed air colder than air from the inside?
233 mixed_warmer = st.hysteresis('mixed_warmer', diff(tmix, tii), -1, 0)
235 # Do we want to boost heat exchanger fan?
236 if ac_on > 0 or (house_hot > 0 and mixed_warmer < 0):
237 st.set('air/exchanger-fan', 255)
239 st.set('air/exchanger-fan', 0)
241 debug("Air: house_warm={} house_hot={} ac_on={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_on, outside_warmer, mixed_warmer))
244 st.set("air/ac-on", "{} {}".format(1 if ac_on > 0 else 0, int(st.now)))
246 st.set("air/ac-on", "")
251 tii = st.get_sensor_avg('air/inside-intake')
252 tie = st.get_sensor_avg('air/inside-exhaust')
253 house_hot = st.hysteresis('ac_house_hot', tii, 23.5, 24)
254 outside_hot = st.hysteresis('ac_outside_hot', tie, 24, 25)
257 if house_hot > 0 and outside_hot > 0:
262 if not hasattr(st, 'last_ac_change'):
263 st.last_ac_change = st.now
264 need_wait = st.last_ac_change + 300 - st.now # FIXME: Increase
267 action = f"wait({need_wait:.0f})"
270 elif ac_on != want_ac:
272 st.send('air/aircon-remote', 'p')
273 st.last_ac_change = st.now
277 debug("AC: house_hot={} outside_hot={} ac_on={} want_ac={} action={}".format(house_hot, outside_hot, ac_on, want_ac, action))
280 def on_connect(mq, userdata, flags, rc):
281 mq.subscribe("burrow/#")
283 def on_message(mq, userdata, msg):
285 # debug("Message {}: {}".format(msg.topic, msg.payload))
286 st.attrs[msg.topic] = msg.payload.decode('utf-8')
288 opts, args = getopt.gnu_getopt(sys.argv[1:], "", ["debug"])
295 mq.on_connect = on_connect
296 mq.on_message = on_message
297 mq.will_set("status/auto", "dead", retain=True)
298 mq.connect("burrow-mqtt")
299 mq.publish("status/auto", "ok", retain=True)
302 # Heuristic delay to get all attributes from MQTT
306 ('loft-fan', auto_loft_fan),
309 ('aircon', auto_aircon),
318 st.update_average('air/outside-intake', 180)
319 st.update_average('air/inside-intake', 180)
320 st.update_average('air/inside-exhaust', 180)
321 st.update_average('air/mixed', 180)
322 st.update_average('temp/loft', 180)
325 for name, func in checks:
326 if st.auto_enabled(name):
332 debug("{} DISABLED".format(name))