[Перевод] Как сделать DIY-термостат с веб-интерфейсом, чтобы отапливать дом дистанционно

569eea6e78d4253d0cc1794d86c1e425.jpg

Разработка на фронтенде не ограничивается интернет-ресурсами, а бекенд может оказаться неожиданным. К старту курса о Fullstack-разработке на Python делимся переводом статьи, автор которой в условиях Великобритании, где центрального отопления в привычном нам смысле этих слов нет, столкнулся с неудобствами отопления в новом доме и самостоятельно собрал электронный термостат, для управления прибором написав веб-интерфейс, а также бекенд на Flask.

Недавно моим родителям установили «умный» термостат. И мне подумалось: неужели я не смогу сам сотворить нечто подобное? Отлично помню себя маленьким — я был одержим технологиями, особенно меня восхищали миниатюрные портативные устройства. Восторг вызывали мини-телевизоры, игровые приставки начала девяностых, наладонники Palm Pilot и коммуникаторы Nokia конца этого же десятилетия, карманные компьютеры, появившиеся на рубеже двухтысячных. Как же я мечтал об этом! И думал, что миниатюрные электронные устройства и системы домашней автоматики — это увлечение сильных мира сего, Брюса Уэйна или Тони Старка. Пока у меня не появилось это чудо:

ad0a9c8b857a296c40252d69b5f26b1d.jpeg

Если хочется сразу перейти к коду, пропустите введение. Конечно же, я знал, что одноплатники существуют — у меня уже несколько лет была модель Pi 3B, которая работала по-разному: как эмулятор игровой консоли, как медиацентр, файловый сервер, веб-сервер, песочница kali linux и т. д. Но будем честными: модель справляется со всеми этими задачами, но без особого блеска. От медиацентра на Raspberry Pi 3 руки чешутся собрать что-то покруче!

Настоящий потенциал Pi я ощутил недавно. Оказывается, мощь компьютера Raspberry Pi кроется в его выводах GPIO (General Purpose Input/Output). Я пересмотрел множество видео на YouTube, ролики канала Explaining Computers от моего любимого Кристофера Барнатта, на которые подписан. В них подробно рассказывается о проектах и пробах с GPIO, но в попытках освоить тонкости хакерского искусства по роликам YouTube у меня не получалось придумать достойный проект, бросить всё и погрузиться в схемотехнику. До экспериментов дело не доходило.

Выводы GPIO на Raspberry Pi SBCВыводы GPIO на Raspberry Pi SBC

Что мешало начать:

  1. Опасение вывести из строя мой Pi.

  2. Кабели-перемычки, модули, платы и т. д, они дорогие, их пришлось бы докупать.

  3. Врождённая лень.

Разберёмся с пунктами.

  1. Приступив к работе, вы сразу поймёте, насколько удобно расположены выводы, большинство из которых в целом одинаковы — нужно только понять, как они расположены.

  2. Затраты окажутся удивительно незначительными, особенно если всё грамотно спланировать. Можно заказать недорогие комплектующие у сторонних производителей, но найти их и дождаться… Это может занять много времени.

Потеряв терпение, ради экспериментов я пожертвовал кабелями, которые вытащил из других мест и наугад подсоединил к выводам GPIO — всё прошло нормально, ничего не сломалось, но я вспомнил пункт #1.

Как настоящий начинающий хакер, я аккуратно подобрал инструменты первой необходимости — паяльники, мелкие отвёртки. Монтажная плата для макетов или тестов так и не понадобилась. И лень — обычное жизненное обстоятельство. Все мы справляемся с ней по-своему. Нужно себя пересилить, придумать идею и разработать план.

Достойный проект

Год назад я переехал в новый дом. Этой зимой во всей красе проявились недостатки центрального отопления, система которого имеет отдельные ответвления вниз и вверх, каждое со своим программатором. Они устроены так, что температура выставляется четыре раза в сутки, а в будни и в выходные система ведёт себя по-разному.

Например, можно запрограммировать нагрев до 20° С в 6 утра, затем снизить до 5° С в 8, потому что дома никого нет и поднять до 20° С в 6 вечера, перед сном снизив температуру до 5° С.

В субботу и воскресенье можно настроить другой режим. Это комбинированная система. У большинства систем, с которыми я имел дело, были отдельные терморегулятор и таймер; на мой взгляд, объединение этих устройств освобождает пространство на стене, но ограничивает функциональность, например потому, что у настенной модели нет кнопки, чтобы на один час усилить отопление.

Когда нужно задать какое-нибудь нестандартное время нагрева, единственное, что можно сделать, — внимательно следить за температурой на экране до какого-то порога и зафиксировать её на этом уровне до следующего изменения, а затем, когда придёт время, убавить её. Эти раздражающие действия автоматизируются, а узнав, что родители приобрели управляемый мобильным приложением термостат, это заставило задуматься, как дистанционно обогреть дом и насколько сложно собрать прибор самому.

Настройка реле отключения через GPIO RPi

Это обычное реле — справа внизу — в нормально разомкнутом положении (N/O)Это обычное реле — справа внизу — в нормально разомкнутом положении (N/O)

Когда температура опускается ниже порогового значения, раздаётся щелчок, а звук реле ни с чем не спутаешь, поэтому я полагал, что схема работает благодаря реле. Бойлер нагревается — температура поднимается; щелчок — и бойлер остывает. В сети я заказал самое недорогое реле, которое работает с Pi. Я был взволнован и даже слегка опасался за первый проект GPIO.

Для своей модели я выбрал реле Adafruit Power Relay Featherwing. Реле на 5 ампер и 250 вольт должно справляться с британским напряжением и надёжно срабатывать от выходного напряжения Pi 3В.

3В подключен к выводу 1, GND — к выводу 9, а сигнал — к выводу 13 (GPIO 27)3В подключен к выводу 1, GND — к выводу 9, а сигнал — к выводу 13 (GPIO 27)

Реле прибыло, я приступил к программированию. Вначале я запустил тестовый скрипт, о котором узнал на канале Explaining Computers.

import RPi.GPIO as GPIO
import time

GPIO.setmode(GPIO.BOARD)

GPIO.setup(13, GPIO.OUT)

try:
    while True:
        GPIO.output(13, True)
        time.sleep(1)
        GPIO.output(13, False)
        time.sleep(1)

finally:
    GPIO.cleanup()

Первое испытание

В примере выше после импорта RPi.GPIO я установил последовательный режим Board Numbering, а затем входным контактом сделал выбранный сигнальный вывод GPIO 27, обозначенный в коде числом 13.

Затем я запустил цикл while, который с задержкой в секунду включает и выключает реле. Блок try/finally перед выходом из программы удаляет настройки. Всё заработало сразу (спасибо, Крис!). Невообразимо приятно было слышать щелчки реле и видеть, как мой скрипт работает с физическим объектом!

Оставалось ещё много свободных контактов, и это было хорошо: нужно было подключить температурный модуль. Я поискал немного и выбрал BMP280 от Bosch — самый недорогой модуль с хорошими отзывами:

1a34b9270010cfbc4771f3c2d64b7bd2.jpg

Ещё пара поисков в сети, и я нашёл полезную схему и руководство о том, как подсоединить этот модуль к Pi:

image-loader.svg

В нашем случае важно знать назначение контактов, поскольку 3 и 5 (GPIO 2(SDA) и 3(SCL)) задействованы последовательной асимметричной шиной для связи между интегральными схемами.

Я выбрал конфигурацию выше, пришлось переместить кабель 3В реле на контакт Pi 3В в позиции 17. В остальном температурный датчик не должен мешать работе реле, я подключил его без проблем:

Температурный модуль, свисающий с временных кабелей (позже я заменил их)Температурный модуль, свисающий с временных кабелей (позже я заменил их)

Конечно же, сначала я написал скрипты для тестирования реле и модуля датчика, и если первая часть это работы не представила для меня никаких затруднений, то со второй пришлось повозиться: передача данных зависела от характеристик I2C-выводов Pi. С ними то и дело возникали проблемы прав доступа, особенно не на Raspbian. Я работаю с Ubuntu 20.04, но всё разрешилось благополучно — достаточно было кое-что поискать и пару раз зайти на Stack Overflow.

Проблема возникала из-за моей давней приятельницы — ошибки PermissionError, срабатывающей при попытке запустить скрипт не от имени root. Неидеальный вариант, если нужно, чтобы скрипт запускался автономно на веб-сервере.

В итоге я нашёл фантастически полезный пакет pigpiowhich, позволяющий обходить эти разрешения, если запущен демон pigpiod. Он может служить как замена RPi.GPIO, в настройке он значительно проще. Установить его на Ubuntu и Raspbian можно так:

sudo apt install pigpiod

Затем устанавливается модуль Python:

pip3 install pigpio

Нужно было учесть другие зависимости, пришлось установить smbus2 и pimoroni-bme280:

pip3 install smbus2 pimoroni-bme280

Устранение неисправностей

Я рекомендую установить i2c-tools, который помог обнаружить проблему плохой пайки.

sudo apt install i2c-tools

Пакет позволяет просмотреть занятые адреса I2C. Если все контакты свободны, вы увидите такой вывод:

image-loader.svg

Если всё установлено, но ничего не работает, возможно, проблема в неаккуратной пайке. Припаивая крохотные GPIO-контакты к Pi Zero W, я не проследил, чтобы каждая капля припоя проникала непосредственно в отверстие. Сразу после исправления ошибки i2cdetect нашёл модуль:

image-loader.svg

В этот момент беспокойство #1 достигло пика. Больше всего я опасался, что случайно припаяю один крохотный контакт к другому и случится короткое замыкание. Но всё обошлось, свой Pi я не повредил, хотя оставил несколько пятен припоя в нижней части платы.

Код

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

import json
import time
from datetime import datetime
from threading import Thread
import pigpio
import requests
from requests.exceptions import ConnectionError


class Heating:
    def __init__(self):
        self.pi = pigpio.pi()
        self.advance = False
        self.advance_start_time = None
        self.on = False
        self.tstat = False
        self.temperature = self.check_temperature()
        self.humidity = self.check_humidity()
        self.pressure = self.check_pressure()
        self.desired_temperature = 20
        self.timer_program = {
            'on_1': '07:30',
            'off_1': '09:30',
            'on_2': '17:30',
            'off_2': '22:00',
        }
  
    def thermostatic_control(self):
        self.tstat = True
        while self.tstat:
            time_check = datetime.strptime(datetime.utcnow().time().strftime('%H:%M'), '%H:%M').time()
            on_1 = datetime.strptime(self.timer_program['on_1'], '%H:%M').time()
            off_1 = datetime.strptime(self.timer_program['off_1'], '%H:%M').time()
            on_2 = datetime.strptime(self.timer_program['on_2'], '%H:%M').time()
            off_2 = datetime.strptime(self.timer_program['off_2'], '%H:%M').time()
            if (on_1 < time_check < off_1) or (on_2 < time_check < off_2):
                if self.check_temperature() < int(self.desired_temperature) and not self.check_state():
                    self.switch_on_relay()
                elif self.check_temperature() > int(self.desired_temperature) + 0.5 and self.check_state():
                    self.switch_off_relay()
                time.sleep(5)
            else:
                if self.check_state():
                    self.switch_off_relay()
                time.sleep(900)
        return

    def thermostat_thread(self):
        self.on = True
        t1 = Thread(target=self.thermostatic_control)
        t1.daemon = True
        t1.start()

    def stop_thread(self):
        self.on = False
        self.tstat = False
        self.switch_off_relay()

    def sensor_api(self):
        try:
            req = requests.get('http://192.168.1.88/')
            data = json.loads(req.text)
            return data
        except ConnectionError:
            return {
                'temperature': self.temperature,
                'humidity': self.humidity,
                'pressure': self.pressure,
            }

    def check_temperature(self):
        self.temperature = self.sensor_api()['temperature']
        return self.temperature

    def check_pressure(self):
        self.pressure = self.sensor_api()['pressure']
        return self.pressure

    def check_humidity(self):
        self.humidity = self.sensor_api()['humidity']
        return self.humidity

    def switch_on_relay(self):
        self.pi.write(27, 1)

    def switch_off_relay(self):
        self.pi.write(27, 0)

    def check_state(self):
        return self.pi.read(27)

    def start_time(self):
        if not self.advance_start_time:
            self.advance_start_time = datetime.now().strftime('%b %d, %Y %H:%M:%S')
        return self.advance_start_time


if __name__ == '__main__':
    hs = Heating()
    while True:
        print(f'''________________________________________________________________
{datetime.utcnow().time()}
Temp: {hs.check_temperature()}
Pressure: {hs.check_pressure()}
Humidity: {hs.check_humidity()}
________________________________________________________________
        ''')
        time.sleep(2)

Я включил в код необходимые Flask проверки: метод start_time() создаёт передаваемую в html-шаблон переменную, чтобы таймер JavaScript отсчитывал время независимо от обновлений страницы и от того, используется ли другое устройство.

API терморегулятора

Как можно заметить, в скрипте нет функции проверки самого температурного модуля. Я сделал так намеренно, потому что считаю удобным, когда Pi 3B работает с реле, а более портативный Pi Zero W через API получает данные от модуля датчика. Код этого API для Flask с методами BME280 выглядит так:

#!/usr/bin/env/python3
import time

import pigpio
from smbus2 import SMBus
from bme280 import BME280

from flask import Flask, jsonify, make_response
app = Flask(__name__)

pi = pigpio.pi()
bus = SMBus(1)
bme = BME280(i2c_dev=bus)

# throwaway readings:
for i in range(3):
    bme.get_temperature()
    bme.get_humidity()
    bme.get_pressure()


@app.route('/')
def sensor_api():
    response = make_response(jsonify({'temperature': bme.get_temperature(),
                                      'humidity': bme.get_humidity(),
                                      'pressure': bme.get_pressure()}))
    response.status_code = 200
    return response

Единственная конечная точка возвращает ответ с текущими показаниями счётчика в формате JSON.

Источник проблем пайки — новая сателлитная система API терморегулятораИсточник проблем пайки — новая сателлитная система API терморегулятора

Реверс-инжинеринг моей системы

Что мне больше всего нравится в электронике? Большинство компонентов имеют понятную маркировку и качественную документацию. Но когда дело касается электричества, всё намного запутаннее! Когда я снял крышку с распределительной коробки, что висела за сушильным шкафом, то обнаружил несколько проводов, подключённых к исполнительному механизму регулирующего клапана, который обслуживает два контура отопления: беспорядочный набор проводов, пропущенных через отверстие в стене, и четыре провода, идущих к каждому исполнительному механизму, то есть всего восемь проводов.

Я поискал и нашёл официальную документацию на клапаны исполнительного механизма и понял, что это за четыре провода и какой из них является «главным», сигнал которого прерывается реле в блоке программатора.

Из моего рассказа может сложиться впечатление, что всё у меня проходило гладко и без запинок. Но дело заняло довольно много времени — достаточно сказать, что все провода на выходе оставшейся части оказались коричневыми. Пытаясь понять, в каком направлении искать, я просмотрел огромное количество роликов на YouTube на тему «как подключить систему отопления в Великобритании».

Затем нужно было просто замкнуть реле на стене и разорвать с его помощью цепь внутри сушильного шкафа, одновременно подавая питание на оба привода. Я также решил добавить в шкаф розетку для подачи питания на Pi. Это позволило спрятать провода, которые моя жена ненавидит. Качество сигнала почти не пострадало, хотя кабель был проложен в шкафу. Это удивительно!

Прокладываю провода по-новому. Реле висит на стене, Pi в чёрной коробке.Прокладываю провода по-новому. Реле висит на стене, Pi в чёрной коробке.

Контур от приводов к насосу был один, его можно было не трогать; два других вели к программаторам на стенах — сверху и снизу. Как уже говорилось, я замкнул одно реле и использовал этот контур для удлинителя розетки. Когда-нибудь я доработаю это решение, а пока пусть повисит так, тем более всё нормально.

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

Вначале я проектировал маршруты на фронтенде параллельно с тестированием системы. Несколько дней пришлось ждать посылку с температурным модулем, поэтому вначале получилась версия только с таймером.

Приложение Flask простое. Я написал маршруты и представления для путей /, /on, /off, /advance и /settings, элементарная аутентификация по простому коду на моём RPi уже работала, я решил оставить её. После кода вы увидите скриншоты интерфейсов.

#!/usr/bin/env python3
import time
from threading import Thread

from flask import Flask, redirect, url_for, render_template, request, session, jsonify, make_response

from .heating import Heating

app = Flask(__name__)

hs = Heating()

# Throwaway temp checks:
hs.check_temperature()
time.sleep(1)
hs.check_temperature()


@app.route('/heating')
def home():
    if 'verified' in session:
        start_time = hs.start_time() if hs.advance else None
        return render_template('heating.html', on=hs.on, relay_on=hs.check_state(),
                               current_temp=int(hs.check_temperature()), desired_temp=int(hs.desired_temperature),
                               advance=hs.advance, time=start_time,
                               )
    return redirect(url_for('login'))


@app.route('/', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        if 'verified' in session:
            return redirect(url_for('menu'))
        return render_template('login.html')
    else:
        name = request.form.get('name')
        if name == 'PASSWORD':
            session['verified'] = True
            return redirect(url_for('menu'))
        else:
            return render_template('login.html', message='You are not allowed to enter.')


@app.route('/menu')
def menu():
    if 'verified' in session:
        return render_template('menu.html')
    return redirect(url_for('login'))


@app.route('/on')
def on():
    if 'verified' in session:
        hs.thermostat_thread()
        return redirect(url_for('home'))
    return redirect(url_for('login'))


@app.route('/off')
def off():
    if 'verified' in session:
        hs.stop_thread()
        hs.advance = False
        hs.advance_start_time = None
        return redirect(url_for('home'))
    return redirect(url_for('login'))


def advance_thread():
    interrupt = False
    if hs.tstat:
        hs.tstat = False
        interrupt = True
    hs.switch_on_relay()
    time.sleep(900)
    hs.switch_off_relay()
    hs.advance = False
    hs.advance_start_time = None
    hs.on = False
    if interrupt:
        hs.thermostat_thread()


@app.route('/advance')
def advance():
    if 'verified' in session:
        hs.on = True
        hs.advance = True
        t1 = Thread(target=advance_thread)
        t1.daemon = True
        t1.start()
        return redirect(url_for('home'))
    return redirect(url_for('login'))


@app.route('/settings', methods=['GET', 'POST'])
def settings():
    if request.method == 'GET':
        if 'verified' in session:
            return render_template('settings.html', des_temp=hs.desired_temperature, timer_prog=hs.timer_program)
        return render_template('login.html')
    else:
        interrupt = False
        if hs.tstat:
            hs.tstat = False
            interrupt = True
        des_temp = request.form.get('myRange')
        on_1 = request.form.get('on_1')
        off_1 = request.form.get('off_1')
        on_2 = request.form.get('on_2')
        off_2 = request.form.get('off_2')
        new_timer_prog = {
            'on_1': on_1,
            'off_1': off_1,
            'on_2': on_2,
            'off_2': off_2
        }
        hs.desired_temperature = des_temp
        hs.timer_program = new_timer_prog
        if interrupt:
            hs.thermostat_thread()
        return redirect(url_for('home'))


@app.route('/temp', methods=['GET'])
def fetch_temp() -> int:
    response = make_response(jsonify({"temp": int(hs.check_temperature()),
                                      "on": hs.check_state()}), 200)
    return response


@app.route('/radio')
def radio():
    return render_template('radio.html')


@app.errorhandler(404)
def page_not_found(e):
    return redirect(url_for('home'))


if __name__ == '__main__':
    app.secret_key = 'SECRET KEY'
    app.run(debug=True, host='0.0.0.0', port=5000)

Исключительно ради стиля я добавил представления и элементы интерфейса. Представление отвечает на асинхронный запрос функции JavaScript, который обновляет температуру на дисплее в реальном времени и при включении реле обозначает её красным цветом:

image-loader.svgimage-loader.svgЖелезо крупным планом

Температурный модуль немного отличается от показанного выше модуля: нет двух дополнительных контактовТемпературный модуль немного отличается от показанного выше модуля: нет двух дополнительных контактовВременные кабели я заменил на кабели-перемычки с цветовой маркировкойВременные кабели я заменил на кабели-перемычки с цветовой маркировкойСобранную систему я убрал за телевизор. Позже выяснилось, что там слишком холодноСобранную систему я убрал за телевизор. Позже выяснилось, что там слишком холодно

Код на GitHub

Если работа с Python не оставляет вас равнодушными и хочется научиться писать на этом языке или поднять навыки владения им на новый уровень, вы можете обратить внимание на наш курс по Fullstack-разработке на Python, а если есть желание чувствовать себя ближе к железу, то вы можете присмотреться к нашему курсу по C++. Также можно узнать о том, как начать карьеру или прокачаться в других направлениях:

image-loader.svg

Data Science и Machine Learning

Python, веб-разработка

Мобильная разработка

Java и C#

От основ — в глубину

А также:

© Habrahabr.ru