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