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

в 12:28, , рубрики: arduino, diy или сделай сам, DropBox, зимовка, умный дом, Электроника для начинающих

веб интерфейс управления температурой зимовника кактусов

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

 

Программа

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

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

Программа на Python для старого ноутбука

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

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 для отображения и управления зимовником

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

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

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

Ссылки

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

Автор: barabanus

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js