]> mj.ucw.cz Git - home-hw.git/blob - auto/burrow-auto
Auto: Make a bit more pythonic
[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)
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 auto_enabled(self, key):
64         topic = "burrow/auto/" + key
65         if topic in self.attrs:
66             return self.attrs[topic] != '0'
67         else:
68             return True
69
70     def hysteresis(self, key, value, low, high):
71         old_state = self.hyst_state.get(key, 0)
72         if value is None:
73             new_state = 0
74         elif old_state <= 0:
75             if value >= high:
76                 new_state = 1
77             else:
78                 new_state = -1
79         else:
80             if value <= low:
81                 new_state = -1
82             else:
83                 new_state = 1
84         self.hyst_state[key] = new_state
85         return new_state
86
87     def update_average(self, key, window_seconds):
88         if key not in self.running_averages:
89             self.running_averages[key] = (deque(), 0, 0, None)
90         (history, sum, count, avg) = self.running_averages[key]
91
92         while len(history) > 0 and history[0][0] <= self.now - window_seconds:
93             if history[0][1] is not None:
94                 sum -= history[0][1]
95                 count -= 1
96             history.popleft()
97
98         curr = self.get_sensor(key)
99         history.append((self.now, curr))
100         if curr is not None:
101             sum += curr
102             count += 1
103
104         if count > len(history) // 2:
105             avg = sum / count
106         else:
107             avg = None
108
109         self.running_averages[key] = (history, sum, count, avg)
110         if avg is None:
111             debug("= avg NONE ({} samples, {} non-null)".format(len(history), count))
112         else:
113             debug("= avg {:.6} ({} samples, {} non-null)".format(avg, len(history), count))
114             self.set("avg/" + key, "{:.6} {}".format(avg, int(self.now)))
115
116     def get_sensor_avg(self, key):
117         val = self.running_averages[key][3]
118         if val is None:
119             debug("< {} = avg NONE".format(key))
120         else:
121             debug("< {} = avg {:.6}".format(key, val))
122         return val
123
124 st = State()
125
126 def on_connect(mq, userdata, flags, rc):
127     mq.subscribe("burrow/#")
128
129 def on_message(mq, userdata, msg):
130     global st
131     # debug("Message {}: {}".format(msg.topic, msg.payload))
132     st.attrs[msg.topic] = msg.payload.decode('utf-8')
133
134 def auto_loft_fan():
135     global st
136     lt = st.get_sensor("temp/loft")
137     lt_high = st.hysteresis('lt_high', lt, 29, 30)
138     lt_mid = st.hysteresis('lt_mid', lt, 24, 25)
139     if lt_high > 0:
140             fs = 3
141     elif lt_mid > 0:
142         if st.hour in range(10, 20):
143                 fs = 3
144         else:
145             fs = 1
146     else:
147         if st.hour in range(8, 22):
148             if st.min % 30 in range(0, 5):
149                 fs = 3
150             else:
151                 fs = 0
152         else:
153             fs = 0
154     # FIXME: Disabled for now
155     fs = 0
156     st.set("loft/fan", fs)
157
158 def auto_circ():
159     global st
160     if st.hour in range(7, 24):
161         c = 1
162     else:
163         c = 0;
164     st.set("loft/circulation", c)
165
166 def auto_air():
167     global st
168     tii = st.get_sensor_avg('air/inside-intake')
169     tie = st.get_sensor_avg('air/inside-exhaust')
170     toi = st.get_sensor_avg('air/outside-intake')
171     tmix = st.get_sensor_avg('air/mixed')
172     house_warm = st.hysteresis('house_warm', tii, 23.5, 24.5)
173     house_hot = st.hysteresis('house_hot', tii, 24.5, 25)
174
175     # Is AC currently on (mixed air is significantly colder than inside exhaust)?
176     if tie is None or tmix is None:
177             ac_off = 1
178     else:
179         ac_off = st.hysteresis('ac_off', tmix, tie - 5, tie - 4)
180
181     # XXX: Temporarily disabled
182     house_warm = -1
183     house_hot = -1
184     ac_off = 1
185
186     # Do we want to bypass the heat exchanger?
187     outside_warmer = st.hysteresis('outside_warmer', diff(toi, tii), -0.5, 0.5)
188     if (house_warm > 0) and (outside_warmer > 0) or \
189        (house_warm < 0) and (outside_warmer < 0):
190         st.set('air/bypass', 0)
191     else:
192         st.set('air/bypass', 1)
193
194     # Is mixed air colder than air from the inside?
195     mixed_warmer = st.hysteresis('mixed_warmer', diff(tmix, tii), -1, 0)
196
197     # Do we want to boost heat exchanger fan?
198     if ac_off < 0 or (house_hot > 0 and mixed_warmer < 0):
199         st.set('air/exchanger-fan', 255)
200     else:
201         st.set('air/exchanger-fan', 0)
202
203     debug("Air: house_warm={} house_hot={} ac_off={} outside_warmer={} mixed_warmer={}".format(house_warm, house_hot, ac_off, outside_warmer, mixed_warmer))
204
205 opts, args = getopt.gnu_getopt(sys.argv[1:], "", ["debug"])
206 for opt in opts:
207     o, arg = opt
208     if o == "--debug":
209         debug_mode = True
210
211 mq = mqtt.Client()
212 mq.on_connect = on_connect
213 mq.on_message = on_message
214 mq.will_set("status/auto", "dead", retain=True)
215 mq.connect("burrow-mqtt")
216 mq.publish("status/auto", "ok", retain=True)
217 mq.loop_start()
218
219 # Heuristic delay to get all attributes from MQTT
220 time.sleep(3)
221
222 checks = [
223     ('loft-fan', auto_loft_fan),
224     ('circ', auto_circ),
225     ('air', auto_air)
226 ]
227
228 while True:
229     st.update()
230
231     debug("averages")
232     indent += 1
233     st.update_average('air/outside-intake', 60)
234     st.update_average('air/inside-intake', 60)
235     st.update_average('air/inside-exhaust', 60)
236     st.update_average('air/mixed', 60)
237     indent -= 1
238
239     for name, func in checks:
240         if st.auto_enabled(name):
241             debug(name)
242             indent += 1
243             func()
244             indent -= 1
245         else:
246             debug("{} DISABLED".format(name))
247     debug("=" * 80)
248     time.sleep(10)