Уже много лет, как жена увлеклась разведением кактусов, а все никак ей не удавалось организовать для них правильную зимовку. Дело в том, что для кактусов очень важно, чтобы зиму они пережили при температуре от 5 до 15 °C — не ниже, чтобы не погибли, и не выше, чтобы не решили, что уже весна. Я хотел бы с вами поделиться, как весьма доступными средствами мне удалось создать систему контроля температуры на Arduino с онлайн управлением через Dropbox.
Исходные материалы
- Arduino Uno с макетной платой
- Микросхема температурного сенсора LM35
- Обогреватель типа «теплодуйка»
- Китайский удлинитель
- Механическое реле (5 В на катушку для управления цепью 220 В)
- Старый ноутбук
Обогреватель и реле
Зимовник организован на балконе, куда не попадает солнце, поэтому там всегда прохладно. Если температура упадет ниже заданного порога, то должен включаться обогреватель, который я подключил к Arduino через механическое реле. Чтобы не разбирать обогреватель, я модифицировал китайский удлинитель:
- Срезал и зачистил с двух сторон неиспользуемый кусочек провода «земли»
- Подключил этот провод к шине вместо одного из «активных» проводов
- Протянул концы обоих проводов через внутреннее отверстие и подсоединил их к реле с лицевой стороны удлинителя
- Подсоединил к реле управляющие провода с удобным терминалом
Теперь Arduino может управлять обогревателем, подключенным через удлинитель!
Принципиальная схема
Плата Arduino Uno подключена через USB к старому ноутбуку. В качестве температурного сенсора я использовал микросхему LM35, линейно отображающую температуру окружающей среды в напряжение.
Для запитки реле необходим отдельный источник питания, поскольку номинальный ток катушки в 110 мА близок к предельному току выдачи Arduino Uno. Первый раз я все-таки использовал питание от Arduino Uno, но показания температурного счетчика сбивались при каждом включении реле, поэтому я организовал питание через отдельное USB-соединение.
Обогреватель подключен к удлинителю, удлинитель — к сети питания. Обогреватель при включении реле сразу начинает греть, но на малой мощности, чтобы не пугать кактусы резкими перепадами температуры.
Программа
Программа для Arduino один раз в секунду опрашивает температурный сенсор и выдает значение температуры через последовательный интерфейс. Кроме мгновенного значения температуры, программа выдает усредненное значение и вектор состояния: режим управления обогревателем (всегда включен / всегда выключен / автоматический) и диапазон температур для автоматического режима. В этом режиме программа включает обогреватель, когда температура опускается ниже первого заданного порога, а выключает, когда поднимается выше второго заданного порога.
const int PIN_HEATER = 10;
const int DELAY_MS = 1000;
const int MAGIC = 10101;
const float TEMP_MAX = 20.0;
enum { OFF = 0, ON, AUTO };
int mode = AUTO;
float tempAverage = NAN;
bool heater = false;
float heaterFrom = 5.f;
float heaterTo = 10.f;
void startHeater() {
digitalWrite(PIN_HEATER, HIGH);
heater = true;
}
void stopHeater() {
digitalWrite(PIN_HEATER, LOW);
heater = false;
}
void setup() {
Serial.begin(9600);
digitalWrite(PIN_HEATER, LOW);
pinMode(PIN_HEATER, OUTPUT);
}
void loop() {
float tempMV = float(analogRead(A0)) / 1024 * 5.0;
float tempCurrent = tempMV / 10e-3;
if (isnan(tempAverage)) {
tempAverage = tempCurrent;
} else {
tempAverage = tempAverage * 0.95f + tempCurrent * 0.05f;
}
if (Serial.available()) {
if (Serial.parseInt() == MAGIC) {
int newMode = Serial.parseInt();
float newHeaterFrom = Serial.parseFloat();
float newHeaterTo = Serial.parseFloat();
if (newMode >= OFF && newMode <= AUTO && newHeaterFrom < newHeaterTo) {
mode = newMode;
heaterFrom = newHeaterFrom;
heaterTo = newHeaterTo;
stopHeater();
}
}
}
bool overheat = tempAverage >= TEMP_MAX;
if (!overheat && (mode == ON || (mode == AUTO && tempAverage <= heaterFrom))) {
startHeater();
}
if (overheat || mode == OFF || (mode == AUTO && tempAverage >= heaterTo)) {
stopHeater();
}
Serial.print("mode = "); Serial.print(mode);
Serial.print(", tempCurrent = "); Serial.print(tempCurrent);
Serial.print(", tempAverage = "); Serial.print(tempAverage);
Serial.print(", heater = "); Serial.print(heater);
Serial.print(", heaterFrom = "); Serial.print(heaterFrom);
Serial.print(", heaterTo = "); Serial.println(heaterTo);
delay(DELAY_MS);
}
На старом ноутбуке стоит Python с установленной библиотекой pySerial. Программа на Python соединяется с Arduino через последовательный интерфейс и каждые десять минут добавляет в файл cactuslog.txt усредненную температуру и вектор состояния устройства. В лог попадает также точное время включения и выключения обогревателя. Если программа обнаруживает командный файл cactuscmd.txt, то содержимое этого файла несколько раз посылается Arduino через последовательный интерфейс, а сам файл переименовывается в cactusini.txt. Этот командный файл выполняется один раз при старте программы, поэтому если будет отключение электричества и перезагрузка системы, то через этот файл она восстановит свое исходное состояние.
###############################################################################
import serial, re
import sys, os, traceback
from datetime import datetime
# Arduino serial port in your system
SERIAL = (sys.platform == "win32") and "COM4" or "/dev/tty.usbmodem1421"
# input / output files
INIFILE = "cactusini.txt"
CMDFILE = "cactuscmd.txt"
LOGFILE = "cactuslog.txt"
# log update period in seconds
UPDATE_PERIOD_SEC = 600
###############################################################################
def execute(cmdfile, **argv):
if os.path.isfile(cmdfile):
try: # input
fcmd = open(cmdfile)
stream.write(((fcmd.read().strip() + " ") * 10).strip())
fcmd.close()
if "renameTo" in argv:
dstfile = argv["renameTo"]
if os.path.isfile(dstfile): os.remove(dstfile)
os.rename(cmdfile, dstfile)
except: traceback.print_exc()
if fcmd and not fcmd.closed: fcmd.close()
firstRun = True
fcmd, flog, timemark, lastState = None, None, None, None
stream = serial.Serial(SERIAL, 9600)
while True:
s = stream.readline()
if "mode" in s:
record = dict(re.findall(r"(w+)s+=s+([-.d]+)", s))
mode, temp = int(record["mode"]), float(record["tempAverage"])
heater = int(record["heater"])
heaterFrom = float(record["heaterFrom"])
heaterTo = float(record["heaterTo"])
state = (mode, heater, heaterFrom, heaterTo)
if firstRun:
execute(INIFILE)
firstRun = False
execute(CMDFILE, renameTo = INIFILE)
timeout = not timemark or
(datetime.now() - timemark).seconds > UPDATE_PERIOD_SEC
if timeout or state != lastState:
output = (datetime.now(), temp, mode, heater, heaterFrom, heaterTo)
output = "%s,%.2f,%d,%d,%.1f,%.1f" % output
try: # output
flog = open(LOGFILE, "a")
flog.write(output + "n")
except: traceback.print_exc()
if flog: flog.close()
print output
timemark = datetime.now()
lastState = state
###############################################################################
Визуализация и Dropbox
Весь проект умещается в одной папке, добавленной в Dropbox. Одна программа на Python запущена на старом ноутбуке, соединенном с Arduino, и работает с логами и командами как с локальными файлами. Другая программа на Python запускается из той же папки на любом компьютере и создает простой HTTP сервер с заданным адресом и портом. Понадобится установка нескольких библиотек для Python: SciPy и dateutil.
Запустив вторую программу, можно следить за температурой в зимовнике прямо из браузера! Сгенерированная страница отображает:
- сглаженный график температур за последние трое суток
- пределы изменения температур за неделю до того
- долгие/короткие включения обогревателя (стрелки/точки)
- текущий режим работы системы с возможностью его изменения
Благодаря Dropbox, данный проект можно запускать не только у себя дома, но и, например, на даче. Dropbox сам будет синхронизировать все файлы, а программы написаны так, как будто они имеют дело только с локальными файлами. Единственное, надо будет позаботиться о возможном отключении электричества и перезагрузке компьютера.
#########################################################################################
import io, os, re, traceback
import BaseHTTPServer, urlparse, base64
import dateutil.parser
import matplotlib, numpy
import scipy.interpolate
from matplotlib import pylab
from itertools import groupby
from datetime import datetime, timedelta
HOST = "stepan.local"
PORT = 8080
USERNAME = "cactus"
PASSWORD = "forever"
LOGFILE = "cactuslog.txt"
CMDFILE = "cactuscmd.txt"
FONT = "Arial"
FONT_SIZE = 12
GRAPH_STEP_SEC = 300
STATS_DAYS_NUM = 7
SMOOTH_WINDOW = 3
MAGIC = 10101
# time difference in seconds between real time and log time
LOG_TIME_OFFSET_SEC = 3600
OFF, ON, AUTO = 0, 1, 2
#########################################################################################
class CactusHandler(BaseHTTPServer.BaseHTTPRequestHandler):
def do_GET(self):
if not self.authorize(): return
url = urlparse.urlparse(self.path)
query = urlparse.parse_qs(url.query)
pending = False
if "mode" in query and "hfrom" in query and "hto" in query:
pending = True
try:
mode = int(query["mode"][0])
heaterFrom = float(query["hfrom"][0])
heaterTo = float(query["hto"][0])
self.update_params(mode, heaterFrom, heaterTo)
except:
traceback.print_exc()
if self.path in [ "/cactus.png", "/favicon.ico" ]:
self.send_image(self.path)
else:
self.send_page(pending)
self.wfile.close()
def authorize(self):
if self.headers.getheader("Authorization") == None:
return self.send_auth()
else:
auth = self.headers.getheader("Authorization")
code = re.match(r"Basic (S+)", auth)
if not code: return self.send_auth()
data = base64.b64decode(code.groups(0)[0])
code = re.match(r"(.*):(.*)", data)
if not code: return self.send_auth()
user, password = code.groups(0)[0], code.groups(0)[1]
if user != USERNAME or password != PASSWORD:
return self.send_auth()
return True
def send_auth(self):
self.send_response(401)
self.send_header("WWW-Authenticate", "Basic realm="Cactus"")
self.send_header("Content-type", "text/html")
self.end_headers()
self.send_default()
self.wfile.close()
return False
def send_default(self):
self.wfile.write("""
<html>
<body style="background:url(data:image/png;base64,{imageCode}) repeat;">
</body>
</html>""".format(imageCode = "iVBORw0KGgoAAAANSUhEUgAAAAYAAAAGCAYAAADgzO9IAAA" +
"AJ0lEQVQIW2NkwA7+M2IR/w8UY0SXAAuCFCNLwAWRJVAEYRIYgiAJALsgBgYb" +
"CawOAAAAAElFTkSuQmCC"))
def address_string(self):
host, port = self.client_address[:2]
return host
def update_params(self, mode, heaterFrom, heaterTo):
if max(mode, heaterFrom, heaterTo) >= MAGIC:
print "invalid params values"
return
fout = open(CMDFILE, "w")
fout.write("%d %d %.1f %.1f" % (MAGIC, mode, heaterFrom, heaterTo))
fout.close()
def send_image(self, path):
filename = os.path.basename(path)
name, ext = os.path.splitext(filename)
fimage = open(filename)
self.send_response(200)
format = { ".png" : "png", ".ico" : "x-icon" }
self.send_header("Content-type", "image/" + format[ext])
self.send_header("Content-length", os.path.getsize(filename))
self.end_headers()
self.wfile.write(fimage.read())
fimage.close()
def fix_time(self, X):
time = X[0].timetuple()
if time.tm_hour == 0 and time.tm_min <= 11:
X[0] -= timedelta(seconds = time.tm_min * 60 + time.tm_sec)
time = X[-1].timetuple()
if time.tm_hour == 23 and time.tm_min >= 49:
offset = (60 - time.tm_min - 1) * 60 + (60 - time.tm_sec - 1)
X[-1] += timedelta(seconds = offset)
def send_page(self, pending):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
data, flog = [ ], None
while not flog:
try: flog = open(LOGFILE)
except: traceback.print_exc()
mode, heater, heaterFrom, heaterTo = AUTO, 0, 5, 10
for s in flog:
row = tuple(s.strip().split(","))
offset = timedelta(seconds = LOG_TIME_OFFSET_SEC)
date = dateutil.parser.parse(row[0]) + offset
temp = float(row[1])
if len(row) == 3:
heater = int(row[2])
elif len(row) >= 3:
mode, heater = int(row[2]), int(row[3])
heaterFrom, heaterTo = float(row[4]), float(row[5])
data.append((date, temp, heater))
nowDate = datetime.now().date()
Yavg = [ [] for foo in numpy.arange(0, 24 * 3600, GRAPH_STEP_SEC) ]
matplotlib.rc("font", family = FONT, size = FONT_SIZE)
fig = pylab.figure(figsize = (964 / 100.0, 350 / 100.0), dpi = 100)
ax = pylab.axes()
for date, points in groupby(data, lambda foo: foo[0].date().isoformat()):
X, Y, H = zip(*points)
deltaDays = (nowDate - X[0].date()).days
if deltaDays > STATS_DAYS_NUM: continue
if len(X) == 1: continue
# convert to same day data
alpha = [1.0, 0.5, 0.3, 0][min(3, deltaDays)]
X = Xsrc = [ datetime.combine(nowDate, foo.time()) for foo in X ]
self.fix_time(X)
# resample X and Y
P = [ (foo - X[0]).seconds for foo in X ]
Q = numpy.arange(0, P[-1], GRAPH_STEP_SEC)
X = [ X[0] + timedelta(seconds = int(foo)) for foo in Q ]
fresample = scipy.interpolate.interp1d(P, Y)
Y = fresample(Q)
# smooth Y
Y = [ Y[0] ] * SMOOTH_WINDOW + list(Y) + [ Y[-1] ] * SMOOTH_WINDOW
window = numpy.ones(SMOOTH_WINDOW * 2 + 1) / float(SMOOTH_WINDOW * 2 + 1)
Y = numpy.convolve(Y, window, 'same')
Y = Y[SMOOTH_WINDOW:-SMOOTH_WINDOW]
fresample = scipy.interpolate.interp1d(Q, Y)
# gather points for stats curve
for i in range(len(Q)):
Yavg[i].append(Y[i])
# plot stats curve
if deltaDays == 3:
self.fix_time(X)
Ymin = [ min(foo or [0]) for foo in Yavg ][:len(X)]
Ymax = [ max(foo or [0]) for foo in Yavg ][:len(X)]
pylab.fill(X + list(reversed(X)), Ymax + list(reversed(Ymin)),
color = "blue", alpha = 0.10)
if alpha == 0: continue
pylab.plot(X, Y, linewidth = 2, color = "blue", alpha = alpha)
# draw heater
for heater, points in groupby(zip(Xsrc, H), lambda foo: foo[1] != 0):
XX, H = zip(*points)
if heater:
p = (XX[0] - X[0]).seconds
x1, y1 = XX[0], fresample(p)
if (XX[-1] - XX[0]).seconds > 600:
x2 = XX[0] + timedelta(seconds = 600)
y2 = fresample(p + 600)
arrow = dict(facecolor = "red", width = 2, headwidth = 6,
frac = 0.40, alpha = alpha, edgecolor = "red")
ax.annotate("", xy = (x2, y2), xytext = (x1, y1),
arrowprops = arrow)
else:
ax.plot(x1, y1, "ro", markersize = 4, mec = "red", alpha = alpha)
ax.xaxis_date()
ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
ax.xaxis.set_major_locator(matplotlib.dates.HourLocator())
ax.xaxis.grid(True, "major")
ax.yaxis.grid(True, "major")
ax.tick_params(axis = "both", which = "major", direction = "out", labelright = True)
ax.tick_params(axis = "x", which = "major", labelsize = 8)
ax.grid(which = "major", alpha = 1.0)
fig.autofmt_xdate()
pylab.tight_layout()
image = io.BytesIO()
pylab.savefig(image, format = "png")
pylab.clf()
image.seek(0)
graph = "<img src='data:image/png;base64,%s'/>" %
base64.b64encode(image.getvalue())
image.close()
pending = pending or os.path.isfile(CMDFILE)
self.wfile.write(re.sub(r"{s", r"{{ ", re.sub(r"s}", r" }}", """
<html>
<head>
<title>Cactus Tracker</title>
<meta http-equiv="refresh" content="{pending};URL='/'">
<style>
body {
font-family: {font}, sans-serif; font-size: {fontSize}pt;
width: 964px; margin: 47px 30px 0 30px; padding: 0;
background-color: white; color: #262626;
}
h1 {
font-size: 24pt; margin: 0; padding-bottom: 4px;
border-bottom: 2px dotted #262626; margin-bottom: 26px;
}
p { margin-left: 38px; margin-bottom: 20px; }
input {
font-family: {font}, sans-serif; font-size: {fontSize}pt;
border: 2px solid #262626; padding: 2px 6px;
}
button {
font-family: {font}, sans-serif; font-size: {fontSize}pt;
padding: 4px 8px; border: 2px solid #262626; border-radius: 10px;
background-color: white; color: #262626; margin: 0 3px;
}
form { display: inline-block; }
.selected, button:hover:not([disabled]) {
cursor: pointer; background-color: #262626; color: white;
}
.selected:hover { cursor: default; }
.heater { width: 50px; text-align: center; margin: 0 3px; }
.pending { opacity: 0.5; }
.hidden { display: none; }
</style>
</head>
<body>
<h1>Cactus Tracker</h1>
<div>{graph}</div>
<div>
<form action="/" class="{transparent}">
<p>Heater:
<button type="submit" name="mode"
class="{modeOn}" value="1" {disabled}> on </button>
<button type="submit" name="mode"
class="{modeOff}" value="0" {disabled}> off </button>
<button type="submit" name="mode"
class="{modeAuto}" value="2" {disabled}> auto </button>
<input type="hidden" name="hfrom" value="{heaterFrom:.0f}"/>
<input type="hidden" name="hto" value="{heaterTo:.0f}"/>
</form>
<form action="/" class="{transparent} {heaterAuto}">
<span style="margin-left: 30px;">
<input type="hidden" name="mode" value="{mode}"/>
heat from
<input name="hfrom" class="heater" maxlength=2
value="{heaterFrom:.0f}" {disabled}/>
to <input name="hto" class="heater" maxlength=2
value="{heaterTo:.0f}" {disabled}/>
°C
<button type="submit" style="visibility: hidden;" {disabled}/>
</span>
</form>
</div>
<div style="position: absolute; top: 7px; left: 760px;">
<img src="cactus.png">
</div>
</body>
</html>
""")).format(
font = FONT,
fontSize = FONT_SIZE,
graph = graph,
mode = mode,
heaterFrom = heaterFrom,
heaterTo = heaterTo,
modeOff = (mode == OFF) and "selected" or "",
modeOn = (mode == ON) and "selected" or "",
modeAuto = (mode == AUTO) and "selected" or "",
pending = pending and "20" or "1200",
disabled = pending and "disabled=true" or "",
transparent = pending and "pending" or "",
heaterAuto = (mode != AUTO) and "hidden" or ""))
#########################################################################################
server = BaseHTTPServer.HTTPServer((HOST, PORT), CactusHandler)
server.serve_forever()
#########################################################################################
Ссылки
Автор: barabanus