Как стать автором
Обновить

Зимовка кактусов с онлайн контролем температуры

Время на прочтение 14 мин
Количество просмотров 43K
веб интерфейс управления температурой зимовника кактусов

Уже много лет, как жена увлеклась разведением кактусов, а все никак ей не удавалось организовать для них правильную зимовку. Дело в том, что для кактусов очень важно, чтобы зиму они пережили при температуре от 5 до 15 °C — не ниже, чтобы не погибли, и не выше, чтобы не решили, что уже весна. Я хотел бы с вами поделиться, как весьма доступными средствами мне удалось создать систему контроля температуры на Arduino с онлайн управлением через Dropbox.

Исходные материалы


  • Arduino Uno с макетной платой
  • Микросхема температурного сенсора LM35
  • Обогреватель типа «теплодуйка»
  • Китайский удлинитель
  • Механическое реле (5 В на катушку для управления цепью 220 В)
  • Старый ноутбук


Обогреватель и реле


Зимовник организован на балконе, куда не попадает солнце, поэтому там всегда прохладно. Если температура упадет ниже заданного порога, то должен включаться обогреватель, который я подключил к Arduino через механическое реле. Чтобы не разбирать обогреватель, я модифицировал китайский удлинитель:

  • Срезал и зачистил с двух сторон неиспользуемый кусочек провода «земли»
  • Подключил этот провод к шине вместо одного из «активных» проводов
  • Протянул концы обоих проводов через внутреннее отверстие и подсоединил их к реле с лицевой стороны удлинителя
  • Подсоединил к реле управляющие провода с удобным терминалом

Теперь Arduino может управлять обогревателем, подключенным через удлинитель!

Этапы модификации удлинителя в реле (фото)
этапы создания реле из китайского удлинителя

реле из китайского удлинителя
 
 

Принципиальная схема


принципиальная схема устройства контроля температуры зимовника кактусов

Плата Arduino Uno подключена через USB к старому ноутбуку. В качестве температурного сенсора я использовал микросхему LM35, линейно отображающую температуру окружающей среды в напряжение.

Для запитки реле необходим отдельный источник питания, поскольку номинальный ток катушки в 110 мА близок к предельному току выдачи Arduino Uno. Первый раз я все-таки использовал питание от Arduino Uno, но показания температурного счетчика сбивались при каждом включении реле, поэтому я организовал питание через отдельное USB-соединение.

Обогреватель подключен к удлинителю, удлинитель — к сети питания. Обогреватель при включении реле сразу начинает греть, но на малой мощности, чтобы не пугать кактусы резкими перепадами температуры.

 

Программа


Программа для Arduino один раз в секунду опрашивает температурный сенсор и выдает значение температуры через последовательный интерфейс. Кроме мгновенного значения температуры, программа выдает усредненное значение и вектор состояния: режим управления обогревателем (всегда включен / всегда выключен / автоматический) и диапазон температур для автоматического режима. В этом режиме программа включает обогреватель, когда температура опускается ниже первого заданного порога, а выключает, когда поднимается выше второго заданного порога.

Программа для Arduino
///////////////////////////////////////////////////////////////////////////////
// Cactus Tracker v1.0.1 / December 8, 2014
// by Maksym Ganenko <buratin.barabanus at Google Mail>
///////////////////////////////////////////////////////////////////////////////
  
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);

  analogReference(INTERNAL);
  for (int i = 0; i < 100; ++i) {
    analogRead(A0);
  }
}

void loop() {
  float tempMV = float(analogRead(A0)) / 1024 * 1.1;
  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. Этот командный файл выполняется один раз при старте программы, поэтому если будет отключение электричества и перезагрузка системы, то через этот файл она восстановит свое исходное состояние.

Программа на Python для старого ноутбука
###############################################################################
# Cactus Tracker v1.0.1 / December 8, 2014
# by Maksym Ganenko <buratin.barabanus at Google Mail>
###############################################################################

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 сам будет синхронизировать все файлы, а программы написаны так, как будто они имеют дело только с локальными файлами. Единственное, надо будет позаботиться о возможном отключении электричества и перезагрузке компьютера.

Программа на Python для отображения и управления зимовником
#########################################################################################
# Cactus Tracker v1.0.5 / January 11, 2015
# by Maksym Ganenko <buratin.barabanus at Google Mail>
#########################################################################################

import io, os, re, traceback
import BaseHTTPServer, urlparse, base64
import dateutil.parser
import matplotlib, numpy
from matplotlib import pylab
from matplotlib.ticker import AutoMinorLocator
from matplotlib.colors import rgb2hex
from datetime import datetime, timedelta
from itertools import groupby

HOST            = "stepan.local"
PORT            = 8080
USERNAME        = "cactus"
PASSWORD        = "forever"

LOGFILE         = "cactuslog.txt"
CMDFILE         = "cactuscmd.txt"

FONT            = "Arial"
FONT_SIZE       = 12

STATS_DAYS_NUM  = 7
SMOOTH_WINDOW   = 9
CURVE_ALPHA     = [1.0, 0.5, 0.25, 0.1]

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, smooth = False, SMOOTH_WINDOW
        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 "smooth" in query:
            try:
                smooth = int(query["smooth"][0])
            except:
                traceback.print_exc()            

        if self.path in [ "/cactus.png", "/favicon.ico" ]:
            self.send_image(self.path)
        else:
            self.send_page(pending, smooth)
        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" }
        aDay = timedelta(days = 1)
        now = datetime.now().strftime('%a, %d %b %Y %H:%M:%S GMT')
        expires = (datetime.now() + aDay).strftime('%a, %d %b %Y %H:%M:%S GMT')
        self.send_header("Content-type", "image/" + format[ext])
        self.send_header("Cache-Control", "public, max-age=" + str(aDay.total_seconds()))
        self.send_header("Date", now)
        self.send_header("Expires", expires)
        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 make_smooth(self, Y, winSize):
        winSize = min(winSize, len(Y) - 2)
        if winSize == 0: return list(Y)
        Y = [ 2 * Y[0] - foo for foo in reversed(Y[1:winSize + 1]) ] + list(Y) \
          + [ 2 * Y[-1] - foo for foo in reversed(Y[-winSize - 1:-1]) ]
        window = numpy.ones(winSize * 2 + 1) / float(winSize * 2 + 1)
        Y = numpy.convolve(Y, window, 'same')
        Y = Y[winSize:-winSize]
        return list(Y)

    def send_page(self, pending, smooth):
        self.send_response(200)
        self.send_header("Content-type", "text/html")
        self.end_headers()

        data, flog = [ ], None
        nowDate = datetime.now().date()

        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))

        stats = [ ]

        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 = CURVE_ALPHA[min(len(CURVE_ALPHA) - 1, deltaDays)]
            tempColor = rgb2hex((1 - alpha, 1 - alpha, 1))
            heaterColor = rgb2hex((1, 1 - alpha, 1 - alpha))
            X = [ datetime.combine(nowDate, foo.time()) for foo in X ]
            self.fix_time(X)
                        
            if deltaDays < len(CURVE_ALPHA) - 1:
                # make smooth and draw
                start = 0
                for heater, group in groupby(zip(Y, H), lambda foo: foo[1]):
                    finish = start + len(list(group))

                    XS = X[start:finish + 1]
                    if heater:
                        YS = Y[start:finish + 1]
                    elif finish + 1 - start < smooth:
                        winSize = (finish + 1 - start) / 2
                        YS = self.make_smooth(Y[start:finish + 1], winSize)
                    else:
                        YS = self.make_smooth(Y[start:finish + 1], smooth)
                    
                    pylab.plot(XS, YS, linewidth = 2,
                        color = heater and heaterColor or tempColor)

                    start = finish
            else:
                for i in range(3):
                    Y = self.make_smooth(Y, smooth)
                self.fix_time(X)
                stats.append((X, Y))

                # plot stats curve
                if deltaDays == len(CURVE_ALPHA) - 1:
                    X0, Y0 = stats.pop(0)
                    for curve in stats:
                        X1, Y1 = curve
                        pylab.fill(X0 + list(reversed(X1)), Y0 + list(reversed(Y1)),
                                   color = tempColor)

        ax.xaxis_date()
        ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%H:%M"))
        ax.xaxis.set_major_locator(matplotlib.dates.HourLocator())
        ax.yaxis.get_major_locator().set_params(integer = True, nbins = 11)
        ax.xaxis.grid(True, "major")
        ax.yaxis.grid(True, "major")

        ticks = ax.yaxis.get_major_locator().bin_boundaries(*ax.get_ylim())
        if len(ticks) >= 2 and round(ticks[1] - ticks[0]) > 1:
            step = int(round(ticks[1] - ticks[0]))
            ax.yaxis.grid(True, "minor")
            ax.yaxis.set_minor_locator(AutoMinorLocator(n = step))
        ax.tick_params(axis = "both", which = "both", 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; margin: 0; }
            .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>
        <table style="width: 100%;" cellspacing=0 cellpadding=0>
            <tr>
                <td align=left>
                    <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}></button>
                        </span>
                    </form>
                </td>
                <td style="opacity: 0.5;" align=right>
                    <span style="margin-right: 40px;">The last {days} days are shown</span>
                </td>
            </tr>
        </table>
        </div>
        <div style="position: absolute; top: 7px; left: 760px;">
            <img src="cactus.png">
        </div>
    </body>
</html>
""")).format(
    font        = FONT,
    fontSize    = FONT_SIZE,
    days        = STATS_DAYS_NUM,
    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()

#########################################################################################


Ссылки




фотография зимовника кактусов с контролем температуры на Arduino


/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\


UPDATE


  • Перевел проект на Arduino Nano, что весьма улучшило эстетику и лаконичность сборки
  • Добавил цифровой датчик влажности DHT-22. Заодно убедился, что температура, измеряемая датчиком, приблизительно равна показаниям LM35, который остался основным датчиком температуры
  • Обнаружил причину, по которой сбивались показания датчика температуры при включении обогревателя: ток реле поднимал землю датчика. Исправил тем, что использовал два разных входа земли контроллера для реле и датчика. Второе питание больше не нужно!
  • Существенно улучшил алгоритм сглаживания графиков

Теги:
Хабы:
+28
Комментарии 32
Комментарии Комментарии 32

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн