shrooms-server/shroom_controller.py

389 lines
11 KiB
Python

import numpy as np
import json
import os
import serial
import subprocess
import threading
import time
SERIAL_PATH = "/dev/ttyACM0"
SERIAL_BAUD = 115200
SAMPLE_PERIOD = 0.5
DECIMATION_RATE = 1
try:
is_mock = os.environ['MOCK']
except KeyError:
is_mock = False
print("controller start")
process = None
def start_process():
global process
if is_mock:
process = subprocess.Popen(["/usr/bin/env", "python", "/home/kelvin/src/shroom-server/shroom_pipe.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
else:
process = subprocess.Popen(["ssh", "shrooms@threefortiethofonehamster.com", "/usr/bin/env", "python3", "/home/shrooms/shrooms-server/shroom_pipe.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
start_process()
def send_update(msg):
global process
process.stdin.write(bytes(json.dumps(msg) + "\n", "utf8"))
process.stdin.flush()
class MockSerial:
def __init__(self):
self.humidity = np.zeros(100)
self.humidifier_on = False
self.humidity[:] = 80
self.humidity[-1] = 20
self.humidity[0] = 20
def flush(self):
pass
def write(self, msg):
if msg == b'h':
print("mock hum toggle")
self.humidifier_on = not self.humidifier_on
def read(self, _):
t = time.time()
temp = 25 + np.sin(0.01*2*np.pi*t) + 0.5*np.sin(0.0001*2*np.pi*t + 7)
# very janky model of humidity diffusion
# fix end conditions
for _ in range(20):
self.humidity[-1] = 0.2*20 + 0.8*self.humidity[-2]
self.humidity[0] = 20
if self.humidifier_on:
self.humidity[20] = 2
# use the gradient to determine the change in humidity
avg = 0.5*(self.humidity[:-2] + self.humidity[2:])
self.humidity[1:-1] += 0.10*(avg - self.humidity[1:-1])
#print(self.humidity)
humidity = self.humidity[60] + np.random.random()*0.003
if self.humidifier_on:
hv = 3.3
else:
hv = 0.0
return bytes("{},{},{}\n".format(humidity, temp, hv), "utf8")
if is_mock:
s = MockSerial()
else:
s = serial.Serial(SERIAL_PATH, SERIAL_BAUD, timeout = 0.3)
print("pausing for bootloader...")
time.sleep(10)
print("pause done")
def reset_serial():
global s
if not is_mock:
s.close()
s = serial.Serial(SERIAL_PATH, SERIAL_BAUD, timeout = 0.3)
time.sleep(10)
class Humidifier:
def __init__(self):
self.off_threshold = 0.4
self.on_threshold = 1.9
self.toggle_cooldown = 7
self._on = False
self.history = np.zeros(10)
self.switch_timeout = 0
@property
def on(self):
return self._on
@on.setter
def on(self, nv):
old_on = self._on
self._on = nv
if nv:
print("send hum on")
else:
print("send hum off")
send_update({"status": {"humidifier": nv}})
@property
def off(self):
return not self.on
def update(self, volts):
self.history[1:] = self.history[:-1]
self.history[0] = volts
#print(self.history)
avg = np.sum(self.history)/self.history.shape[0]
if self.on:
if avg < self.off_threshold:
self.on = False
self.switch_timeout = time.time() + 1
else:
if avg > self.on_threshold:
self.on = True
self.switch_timeout = time.time() + 1
def toggle(self, s):
if time.time() > self.switch_timeout:
s.write(b"h")
s.flush()
self.switch_timeout = time.time() + self.toggle_cooldown
# the wiring's a little different so the thresholds for detecting on/off are inverted but hopefully a lot more reliable than the original
class HumidifierV2:
def __init__(self, toggle_cmd=b"i", humidifier_id="humidifier2"):
self.on_threshold = 1.5
self.off_threshold = 2.5
self.toggle_cooldown = 7
self.toggle_command = toggle_cmd
self._on = False
self.history = np.zeros(10)
self.switch_timeout = 0
@property
def on(self):
return self._on
@on.setter
def on(self, nv):
old_on = self._on
self._on = nv
if nv:
print("send hum 2 on")
else:
print("send hum 2 off")
send_update({"status": {humidifier_id: nv}})
@property
def off(self):
return not self.on
def update(self, volts):
self.history[1:] = self.history[:-1]
self.history[0] = volts
#print(self.history)
avg = np.sum(self.history)/self.history.shape[0]
if self.on:
if avg > self.off_threshold:
self.on = False
self.switch_timeout = time.time() + 1
else:
if avg < self.on_threshold:
self.on = True
self.switch_timeout = time.time() + 1
def toggle(self, s):
if time.time() > self.switch_timeout:
s.write(self.toggle_command)
s.flush()
self.switch_timeout = time.time() + self.toggle_cooldown
class Controller:
def __init__(self):
self.target_lower = 85
self.target_upper = 90
self.feedforward_coeff = 50
self.last_toggle = 0
self._manual_mode = False
self.manual_on = False
self.manual_timeout = 0
self.manual_duration = 40
self.humidifier_history = np.zeros(50)
self.first_sample = False
@property
def manual_mode(self):
return self._manual_mode
@manual_mode.setter
def manual_mode(self, on):
self._manual_mode = on
send_update({"status": {"manual_mode": on}})
def toggle_checked(self, humidifier, s):
if time.time() - self.last_toggle > 0.8:
humidifier.toggle(s)
self.last_toggle = time.time()
def update(self, humidifier, humidifier2, humidity):
if self.first_sample:
self.humidifier_history[:] = humidity
self.first_sample = False
else:
self.humidifier_history[:-1] = self.humidifier_history[1:]
self.humidifier_history[-1] = humidity
# compensate for the slow response time by adding a little feed forward
# using the slope of the humidifier data
slope = (self.humidifier_history[-1] - self.humidifier_history[0])/self.humidifier_history.shape[0]
comp_humidity = humidity + self.feedforward_coeff*slope
if self.manual_mode and time.time() > self.manual_timeout:
self.manual_mode = False
# TODO add in support for manual control of second humidifier
if self.manual_mode:
if humidifier.off and self.manual_on:
self.toggle_checked(humidifier, s)
elif humidifier.on and not self.manual_on:
self.toggle_checked(humidifier, s)
if humidifier2.off and self.manual_on:
self.toggle_checked(humidifier2, s)
elif humidifier2.on and not self.manual_on:
self.toggle_checked(humidifier2, s)
else:
if comp_humidity < self.target_lower:
if humidifier.off:
self.toggle_checked(humidifier, s)
if humidifier2.off:
self.toggle_checked(humidifier2, s)
elif comp_humidity > self.target_upper:
if humidifier.on:
self.toggle_checked(humidifier, s)
if humidifier2.on:
self.toggle_checked(humidifier2, s)
humidifier = HumidifierV2(b"h", "humidifier")
humidifier2 = HumidifierV2()
controller = Controller()
exiting = False
# run thread to process data from process's stdout
def stdout_loop():
global process, controller, humidifier, humidifier2
while not exiting:
msg = process.stdout.readline()
if len(msg) <= 1:
continue
print("got message ", msg)
try:
msg_js = json.loads(msg)
if "query_params" in msg_js:
if msg_js["query_params"]:
send_update({"params": {
"target_lower": controller.target_lower,
"target_upper": controller.target_upper,
"feedforward_coeff": controller.feedforward_coeff,
"manual_timeout": controller.manual_timeout,
"manual_duration_s": controller.manual_duration,
"manual_mode": 1.0 if controller.manual_mode else 0.0,
"manual_hum_on": 1.0 if controller.manual_on else 0.0,
"off_threshold_volts": humidifier.off_threshold,
"on_threshold_volts": humidifier.on_threshold,
"toggle_cooldown": humidifier.toggle_cooldown
}
})
elif "set_params" in msg_js:
if type(msg_js["set_params"]) is dict:
set_params = msg_js["set_params"]
if "name" in set_params and "value" in set_params:
name, value = set_params["name"], set_params["value"]
if type(value) is float:
if name == "target_lower":
controller.target_lower = value
elif name == "target_upper":
controller.target_upper = value
elif name == "feedforward_coeff":
controller.feedforward_coeff = value
elif name == "manual_timeout":
controller.manual_timeout = value
elif name == "manual_duration_s":
controller.manual_duration = value
elif name == "off_threshold_volts":
humidifier.off_threshold = value
elif name == "on_threshold_volts":
humidifier.on_threshold = value
elif name == "toggle_cooldown":
humidifier.toggle_cooldown = value
elif name == "off_threshold_volts2":
humidifier2.off_threshold = value
elif name == "on_threshold_volts2":
humidifier2.on_threshold = value
elif name == "toggle_cooldown2":
humidifier2.toggle_cooldown = value
elif "manual_mode" in msg_js:
controller.manual_mode = msg_js["manual_mode"]
if controller.manual_mode:
controller.manual_timeout = time.time() + controller.manual_duration
elif "manual_mode_on" in msg_js:
controller.manual_on = msg_js["manual_mode_on"]
except json.JSONDecodeError as e:
print("received bad json ", msg)
stdout_thread = threading.Thread(target=stdout_loop)
stdout_thread.start()
frame_num = 0
last_sample = 0
try:
while True:
now = time.time()
if now - last_sample < SAMPLE_PERIOD:
time.sleep(SAMPLE_PERIOD - (now - last_sample) + 0.001)
continue
last_sample = now
#print("write s")
s.write(b"s")
s.flush()
resp = s.read(120)
#print("read", resp)
if len(resp) == 0:
reset_serial()
time.sleep(5)
continue
parts = resp.split(b",")
humidity = float(parts[0])
temp = float(parts[1])
volts = float(parts[2])
volts2 = float(parts[3])
try:
humidifier.update(volts)
humidifier2.update(volts2)
controller.update(humidifier, humidifier2, humidity)
if frame_num == 0:
#print(humidity, temp, volts)
update = {
"data": {
"time": int(now*1000),
"temp": temp,
"hum": humidity,
"hv": volts,
"hv2": volts2,
}
}
send_update(update)
frame_num = (frame_num + 1) % DECIMATION_RATE
except Exception as e:
print("pipe errored out, restarting: ", e)
# restart the process I guess
exiting = True
process.kill()
time.sleep(0.1)
stdout_thread.join()
exiting = False
start_process()
stdout_thread = threading.Thread(target=stdout_loop)
stdout_thread.start()
finally:
# kill ssh connection
exiting = True
process.kill()
stdout_thread.join()