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

Что мешало начать:
Опасение вывести из строя мой Pi.
Кабели-перемычки, модули, платы и т. д, они дорогие, их пришлось бы докупать.
Врождённая лень.
Разберёмся с пунктами.
Приступив к работе, вы сразу поймёте, насколько удобно расположены выводы, большинство из которых в целом одинаковы — нужно только понять, как они расположены.
Затраты окажутся удивительно незначительными, особенно если всё грамотно спланировать. Можно заказать недорогие комплектующие у сторонних производителей, но найти их и дождаться… Это может занять много времени.
Потеряв терпение, ради экспериментов я пожертвовал кабелями, которые вытащил из других мест и наугад подсоединил к выводам GPIO — всё прошло нормально, ничего не сломалось, но я вспомнил пункт #1.
Как настоящий начинающий хакер, я аккуратно подобрал инструменты первой необходимости — паяльники, мелкие отвёртки. Монтажная плата для макетов или тестов так и не понадобилась. И лень — обычное жизненное обстоятельство. Все мы справляемся с ней по-своему. Нужно себя пересилить, придумать идею и разработать план.
Достойный проект
Год назад я переехал в новый дом. Этой зимой во всей красе проявились недостатки центрального отопления, система которого имеет отдельные ответвления вниз и вверх, каждое со своим программатором. Они устроены так, что температура выставляется четыре раза в сутки, а в будни и в выходные система ведёт себя по-разному.
Например, можно запрограммировать нагрев до 20° С в 6 утра, затем снизить до 5° С в 8, потому что дома никого нет и поднять до 20° С в 6 вечера, перед сном снизив температуру до 5° С.
В субботу и воскресенье можно настроить другой режим. Это комбинированная система. У большинства систем, с которыми я имел дело, были отдельные терморегулятор и таймер; на мой взгляд, объединение этих устройств освобождает пространство на стене, но ограничивает функциональность, например потому, что у настенной модели нет кнопки, чтобы на один час усилить отопление.
Ко��да нужно задать какое-нибудь нестандартное время нагрева, единственное, что можно сделать, — внимательно следить за температурой на экране до какого-то порога и зафиксировать её на этом уровне до следующего изменения, а затем, когда придёт время, убавить её. Эти раздражающие действия автоматизируются, а узнав, что родители приобрели управляемый мобильным приложением термостат, это заставило задуматься, как дистанционно обогреть дом и насколько сложно собрать прибор самому.
Настройка реле отключения через GPIO RPi

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

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

Реверс-инжинеринг моей системы
Что мне больше всего нравится в электронике? Большинство компонентов имеют понятную маркировку и качественную документацию. Но когда дело касается электричества, всё намного запутаннее! Когда я снял крышку с распределительной коробки, что висела за сушильным шкафом, то обнаружил несколько проводов, подключённых к исполнительному механизму регулирующего клапана, который обслуживает два контура отопления: беспорядочный набор проводов, пропущенных через отверстие в стене, и четыре провода, идущих к каждому исполнительному механизму, то есть всего восемь проводов.
Я поискал и нашёл официальную документацию на клапаны исполнительного механизма и понял, что это за четыре провода и какой из них является «главным», сигнал которого прерывается реле в блоке программатора.
Из моего рассказа может сложиться впечатление, что всё у меня проходило гладко и без запинок. Но дело заняло довольно много времени — достаточно сказать, что все провода на выходе оставшейся части оказались коричневыми. Пытаясь понять, в каком направлении искать, я просмотрел огромное количество роликов на YouTube на тему «как подключить систему отопления в Великобритании».
Затем нужно было просто замкнуть реле на стене и разорвать с его помощью цепь внутри сушильного шкафа, одновременно подавая питание на оба привода. Я также решил добавить в шкаф розетку для подачи питания на 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, который обновляет температуру на дисплее в реальном времени и при включении реле обозначает её красным цветом:


Железо крупным планом



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

Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также:
