[Перевод] Как сделать DIY-термостат с веб-интерфейсом, чтобы отапливать дом дистанционно
Разработка на фронтенде не ограничивается интернет-ресурсами, а бекенд может оказаться неожиданным. К старту курса о Fullstack-разработке на Python делимся переводом статьи, автор которой в условиях Великобритании, где центрального отопления в привычном нам смысле этих слов нет, столкнулся с неудобствами отопления в новом доме и самостоятельно собрал электронный термостат, для управления прибором написав веб-интерфейс, а также бекенд на Flask.
Недавно моим родителям установили «умный» термостат. И мне подумалось: неужели я не смогу сам сотворить нечто подобное? Отлично помню себя маленьким — я был одержим технологиями, особенно меня восхищали миниатюрные портативные устройства. Восторг вызывали мини-телевизоры, игровые приставки начала девяностых, наладонники Palm Pilot и коммуникаторы Nokia конца этого же десятилетия, карманные компьютеры, появившиеся на рубеже двухтысячных. Как же я мечтал об этом! И думал, что миниатюрные электронные устройства и системы домашней автоматики — это увлечение сильных мира сего, Брюса Уэйна или Тони Старка. Пока у меня не появилось это чудо:
Если хочется сразу перейти к коду, пропустите введение. Конечно же, я знал, что одноплатники существуют — у меня уже несколько лет была модель Pi 3B, которая работала по-разному: как эмулятор игровой консоли, как медиацентр, файловый сервер, веб-сервер, песочница kali linux и т. д. Но будем честными: модель справляется со всеми этими задачами, но без особого блеска. От медиацентра на Raspberry Pi 3 руки чешутся собрать что-то покруче!
Настоящий потенциал Pi я ощутил недавно. Оказывается, мощь компьютера Raspberry Pi кроется в его выводах GPIO (General Purpose Input/Output). Я пересмотрел множество видео на YouTube, ролики канала Explaining Computers от моего любимого Кристофера Барнатта, на которые подписан. В них подробно рассказывается о проектах и пробах с GPIO, но в попытках освоить тонкости хакерского искусства по роликам YouTube у меня не получалось придумать достойный проект, бросить всё и погрузиться в схемотехнику. До экспериментов дело не доходило.
Выводы GPIO на Raspberry Pi SBC
Что мешало начать:
Опасение вывести из строя мой Pi.
Кабели-перемычки, модули, платы и т. д, они дорогие, их пришлось бы докупать.
Врождённая лень.
Разберёмся с пунктами.
Приступив к работе, вы сразу поймёте, насколько удобно расположены выводы, большинство из которых в целом одинаковы — нужно только понять, как они расположены.
Затраты окажутся удивительно незначительными, особенно если всё грамотно спланировать. Можно заказать недорогие комплектующие у сторонних производителей, но найти их и дождаться… Это может занять много времени.
Потеряв терпение, ради экспериментов я пожертвовал кабелями, которые вытащил из других мест и наугад подсоединил к выводам GPIO — всё прошло нормально, ничего не сломалось, но я вспомнил пункт #1.
Как настоящий начинающий хакер, я аккуратно подобрал инструменты первой необходимости — паяльники, мелкие отвёртки. Монтажная плата для макетов или тестов так и не понадобилась. И лень — обычное жизненное обстоятельство. Все мы справляемся с ней по-своему. Нужно себя пересилить, придумать идею и разработать план.
Достойный проект
Год назад я переехал в новый дом. Этой зимой во всей красе проявились недостатки центрального отопления, система которого имеет отдельные ответвления вниз и вверх, каждое со своим программатором. Они устроены так, что температура выставляется четыре раза в сутки, а в будни и в выходные система ведёт себя по-разному.
Например, можно запрограммировать нагрев до 20° С в 6 утра, затем снизить до 5° С в 8, потому что дома никого нет и поднять до 20° С в 6 вечера, перед сном снизив температуру до 5° С.
В субботу и воскресенье можно настроить другой режим. Это комбинированная система. У большинства систем, с которыми я имел дело, были отдельные терморегулятор и таймер; на мой взгляд, объединение этих устройств освобождает пространство на стене, но ограничивает функциональность, например потому, что у настенной модели нет кнопки, чтобы на один час усилить отопление.
Когда нужно задать какое-нибудь нестандартное время нагрева, единственное, что можно сделать, — внимательно следить за температурой на экране до какого-то порога и зафиксировать её на этом уровне до следующего изменения, а затем, когда придёт время, убавить её. Эти раздражающие действия автоматизируются, а узнав, что родители приобрели управляемый мобильным приложением термостат, это заставило задуматься, как дистанционно обогреть дом и насколько сложно собрать прибор самому.
Настройка реле отключения через GPIO RPi
Это обычное реле — справа внизу — в нормально разомкнутом положении (N/O)
Когда температура опускается ниже порогового значения, раздаётся щелчок, а звук реле ни с чем не спутаешь, поэтому я полагал, что схема работает благодаря реле. Бойлер нагревается — температура поднимается; щелчок — и бойлер остывает. В сети я заказал самое недорогое реле, которое работает с Pi. Я был взволнован и даже слегка опасался за первый проект GPIO.
Для своей модели я выбрал реле Adafruit Power Relay Featherwing. Реле на 5 ампер и 250 вольт должно справляться с британским напряжением и надёжно срабатывать от выходного напряжения Pi 3В.
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 — самый недорогой модуль с хорошими отзывами:
Ещё пара поисков в сети, и я нашёл полезную схему и руководство о том, как подсоединить этот модуль к Pi:
В нашем случае важно знать назначение контактов, поскольку 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. Если все контакты свободны, вы увидите такой вывод:
Если всё установлено, но ничего не работает, возможно, проблема в неаккуратной пайке. Припаивая крохотные GPIO-контакты к Pi Zero W, я не проследил, чтобы каждая капля припоя проникала непосредственно в отверстие. Сразу после исправления ошибки i2cdetect нашёл модуль:
В этот момент беспокойство #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 терморегулятора
Реверс-инжинеринг моей системы
Что мне больше всего нравится в электронике? Большинство компонентов имеют понятную маркировку и качественную документацию. Но когда дело касается электричества, всё намного запутаннее! Когда я снял крышку с распределительной коробки, что висела за сушильным шкафом, то обнаружил несколько проводов, подключённых к исполнительному механизму регулирующего клапана, который обслуживает два контура отопления: беспорядочный набор проводов, пропущенных через отверстие в стене, и четыре провода, идущих к каждому исполнительному механизму, то есть всего восемь проводов.
Я поискал и нашёл официальную документацию на клапаны исполнительного механизма и понял, что это за четыре провода и какой из них является «главным», сигнал которого прерывается реле в блоке программатора.
Из моего рассказа может сложиться впечатление, что всё у меня проходило гладко и без запинок. Но дело заняло довольно много времени — достаточно сказать, что все провода на выходе оставшейся части оказались коричневыми. Пытаясь понять, в каком направлении искать, я просмотрел огромное количество роликов на YouTube на тему «как подключить систему отопления в Великобритании».
Затем нужно было просто замкнуть реле на стене и разорвать с его помощью цепь внутри сушильного шкафа, одновременно подавая питание на оба привода. Я также решил добавить в шкаф розетку для подачи питания на 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, который обновляет температуру на дисплее в реальном времени и при включении реле обозначает её красным цветом:
Железо крупным планом
Код на GitHub
Если работа с Python не оставляет вас равнодушными и хочется научиться писать на этом языке или поднять навыки владения им на новый уровень, вы можете обратить внимание на наш курс по Fullstack-разработке на Python, а если есть желание чувствовать себя ближе к железу, то вы можете присмотреться к нашему курсу по C++. Также можно узнать о том, как начать карьеру или прокачаться в других направлениях:
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также: