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