]> mj.ucw.cz Git - home-hw.git/blob - auto/burrow-auto
burrow-auto: Meditation hack
[home-hw.git] / auto / burrow-auto
1 #!/usr/bin/python3
2
3 from collections import deque
4 import getopt
5 import os
6 import paho.mqtt.client as mqtt
7 import sys
8 import time
9
10 debug_mode = False
11 log_name = '/run/burrow-auto'
12 log_file = None
13 indent = 0
14
15 def debug(msg):
16     if debug_mode:
17         print(("\t" * indent) + msg, file=sys.stderr)
18     elif log_file is not None:
19         print(("\t" * indent) + msg, file=log_file)
20
21 def debug_open():
22     if not debug_mode:
23         global log_file
24         log_file = open(log_name + '.new', 'w')
25
26 def debug_close():
27     if debug_mode:
28         debug("=" * 80)
29     else:
30         global log_file
31         log_file.close()
32         log_file = None
33         os.rename(log_name + '.new', log_name)
34
35 def diff(x, y):
36     if x is None or y is None:
37         return None
38     else:
39         return x - y
40
41 class State:
42     def __init__(self):
43         self.attrs = {}
44         self.hyst_state = {}
45         self.running_averages = {}
46
47     def update(self):
48         self.now = time.time()
49         tm = time.localtime(self.now)
50         self.year = tm.tm_year
51         self.month = tm.tm_mon
52         self.day = tm.tm_mday
53         self.hour = tm.tm_hour
54         self.min = tm.tm_min
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,
59             self.wday
60             ))
61
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))
68                 return None
69             else:
70                 debug("< {} = {}".format(key, s[0]))
71                 return float(s[0])
72         else:
73             debug("< {} UNDEFINED".format(key))
74             return None
75
76     def set(self, key, val):
77         global mq
78         topic = "burrow/" + key
79         debug("> {} = {}".format(topic, val))
80         mq.publish(topic, val, qos=1, retain=True)
81
82     def send(self, key, val):
83         global mq
84         topic = "burrow/" + key
85         debug("> {} := {}".format(topic, val))
86         mq.publish(topic, val, qos=1, retain=False)
87
88     def auto_enabled(self, key):
89         topic = "burrow/auto/" + key
90         if topic in self.attrs:
91             return self.attrs[topic] != '0'
92         else:
93             return True
94
95     def hysteresis(self, key, value, low, high):
96         old_state = self.hyst_state.get(key, 0)
97         if value is None:
98             new_state = 0
99         elif old_state <= 0:
100             if value >= high:
101                 new_state = 1
102             else:
103                 new_state = -1
104         else:
105             if value <= low:
106                 new_state = -1
107             else:
108                 new_state = 1
109         self.hyst_state[key] = new_state
110         return new_state
111
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]
116
117         while len(history) > 0 and history[0][0] <= self.now - window_seconds:
118             if history[0][1] is not None:
119                 sum -= history[0][1]
120                 count -= 1
121             history.popleft()
122
123         curr = self.get_sensor(key)
124         history.append((self.now, curr))
125         if curr is not None:
126             sum += curr
127             count += 1
128
129         if count > len(history) // 2:
130             avg = sum / count
131         else:
132             avg = None
133
134         self.running_averages[key] = (history, sum, count, avg)
135         if avg is None:
136             debug("= avg NONE ({} samples, {} non-null)".format(len(history), count))
137         else:
138             debug("= avg {:.6} ({} samples, {} non-null)".format(avg, len(history), count))
139             self.set("avg/" + key, "{:.6} {}".format(avg, int(self.now)))
140
141     def get_sensor_avg(self, key):
142         val = self.running_averages[key][3]
143         if val is None:
144             debug("< {} = avg NONE".format(key))
145         else:
146             debug("< {} = avg {:.6}".format(key, val))
147         return val
148
149 st = State()
150
151
152 def auto_loft_fan():
153     global st
154     lt = st.get_sensor_avg("temp/loft")
155     out = st.get_sensor_avg('air/outside-intake')
156
157     if st.hour in range(21, 24) or st.hour in range(6, 10):
158         fs = 0
159     elif lt is None or out is None:
160         fs = 0
161     elif st.hysteresis('lf_out_cold', out, 5, 6) < 0:
162         fs = 0
163     elif st.hysteresis('lf_out_cool', out, 14, 15) < 0:
164         if st.min in range(10, 15):
165             fs = 2
166         else:
167             fs = 0
168     elif ac_is_on() > 0:
169         fs = 3
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:
172             fs = 3
173         else:
174             fs = 1
175     else:
176         if st.min in range(10, 15):
177             fs = 2
178         else:
179             fs = 0
180
181     st.set("loft/fan", fs)
182
183
184 def auto_circ():
185     global st
186     if st.hour in range(7, 24):
187         c = 1
188     else:
189         c = 0;
190     st.set("loft/circulation", c)
191
192
193 def ac_is_on():
194     # Heuristics to tell if AC is currently on
195
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:
199         return 0
200
201     # Mixed air is significantly colder than inside exhaust
202     ac_on = -st.hysteresis('ac_off', tmix, tie - 5, tie - 4)
203
204     # FIXME: It might also mean that the loft is cold
205
206     return ac_on
207
208
209 def auto_air():
210     global st
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)
217     ac_on = ac_is_on()
218
219     # XXX: Temporarily disabled
220     #house_warm = -1
221     #house_hot = -1
222     #ac_on = -1
223
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)
229     else:
230         st.set('air/bypass', 1)
231
232     # Is mixed air colder than air from the inside?
233     mixed_warmer = st.hysteresis('mixed_warmer', diff(tmix, tii), -1, 0)
234
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)
238     else:
239         st.set('air/exchanger-fan', 0)
240
241     debug("Air: house_warm={} house_hot={} ac_on={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_on, outside_warmer, mixed_warmer))
242
243     if ac_on != 0:
244         st.set("air/ac-on", "{} {}".format(1 if ac_on > 0 else 0, int(st.now)))
245     else:
246         st.set("air/ac-on", "")
247
248
249 def auto_aircon():
250     global st
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)
255     ac_on = ac_is_on()
256
257     if house_hot > 0 and outside_hot > 0:
258         want_ac = 1
259     else:
260         want_ac = -1
261
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
265
266     if need_wait > 0:
267         action = f"wait({need_wait:.0f})"
268     elif ac_on == 0:
269         action = "need-data"
270     elif ac_on != want_ac:
271         action = "change"
272         st.send('air/aircon-remote', 'p')
273         st.last_ac_change = st.now
274     else:
275         action = "none"
276
277     debug("AC: house_hot={} outside_hot={} ac_on={} want_ac={} action={}".format(house_hot, outside_hot, ac_on, want_ac, action))
278
279
280 def on_connect(mq, userdata, flags, rc):
281     mq.subscribe("burrow/#")
282
283 def on_message(mq, userdata, msg):
284     global st
285     # debug("Message {}: {}".format(msg.topic, msg.payload))
286     st.attrs[msg.topic] = msg.payload.decode('utf-8')
287
288 opts, args = getopt.gnu_getopt(sys.argv[1:], "", ["debug"])
289 for opt in opts:
290     o, arg = opt
291     if o == "--debug":
292         debug_mode = True
293
294 mq = mqtt.Client()
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)
300 mq.loop_start()
301
302 # Heuristic delay to get all attributes from MQTT
303 time.sleep(3)
304
305 checks = [
306     ('loft-fan', auto_loft_fan),
307     ('circ', auto_circ),
308     ('air', auto_air),
309     ('aircon', auto_aircon),
310 ]
311
312 while True:
313     debug_open()
314     st.update()
315
316     debug("averages")
317     indent += 1
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)
323     indent -= 1
324
325     for name, func in checks:
326         if st.auto_enabled(name):
327             debug(name)
328             indent += 1
329             func()
330             indent -= 1
331         else:
332             debug("{} DISABLED".format(name))
333
334     debug_close()
335     time.sleep(10)