IoT устройство на ESP32

Введение

Написание графического интерфейса для взаимодействия с микроконтроллером может занять достаточно много времени на изучение фреймворков, а также написание протокола для взаимодействия через UART. При прототипировании устройств это может занять много времени. Взаимодействие через браузер позволяет работать как с телефона, так и компьютера, при этом взаимодействие будет происходит через i2c или SPI, что освобождает от необходимости использования протоколов. Платформа ESP32 c прошивкой micropython позволяет не только работать в качестве web-server, но и вести лог-файл событий с записью прямо во флеш-память, откуда потом файл можно скопировать.

Установка прошивки на ESP32

После подключения ESP32 необходимо установить драйверы на ESP32 чаще всего ставят CH340 или CP2102. После их установки на потребуется установить программу, а далее прошить в нее интерпретатор MicroPython, в котором мы будем писать наш сервер.

После установки прошивки нам необходимо создать новый файл main.py и сохранить его на устройстве. Одна из причин, написание я программы в main.py, а не в boot.py, это более стабильная работа сигнала прерывания выполнения бесконечного цикла, которая вызывается комбинацией клавиш Ctrl+C. Если вы используете прерывание по таймеру, то выполнить прерывание работы порой можно только при перезагрузки с помощью кнопки EN или комбинацией клавиш Ctrl+D с последующим вызовом комбинаций Cntr+C, для удобства можно ставить задержку перед запуском основного скрипта на 2 секунды time.sleep(2)

C помощью боковой панели Файлы мы можем копировать в память файлы HTML, CSS, javascript и фотографии.

Пришло время написать первую прошивку для тестирования. Скрипт мы будет писать в main.py. В качестве примера помигает светодиодом, а также выведем в консоль время включения-отключения и количество доступной памяти. Консоль — это наш главный помощник в отладке кода на micropython. Для прерывания программы нажмем Ctrl+C.

 from machine import Pin,freq import time import gc import os import machine   if __name__ == "__main__":   # инициализируем пин для управления светодиодом    led=Pin(2,Pin.OUT)    m=os.statvfs("/")    f=machine.freq()       while True:        led.value(1)        data=time.localtime()        print(f"{data[3]:02d}:{data[4]:02d}:{data[5]:02d} - led ON")        mem=gc.mem_free()        print(f"Осталось памяти ОЗУ {mem} байт")        time.sleep_ms(100)        led.value(0)        data=time.localtime()        print(f"{data[3]:02d}:{data[4]:02d}:{data[5]:02d} - led OFF")        mem=gc.mem_free()        print(f"Осталось памяти ОЗУ {mem} байт")        print(f"Программной памяти доступно {m[0]*m[3]} байт")        print(f"частоты работы {f} Гц")        time.sleep_ms(100)    

 

При запуске этого простого примера хотелось бы обратить внимание на такую вещь, как сборщик мусора или очищение кучи. Если в результате работы программы памяти не останется совсем, то контроллер перезагрузиться. А так как любое действие вызывает перезапись объектов и загрузку кучи, то частые вызовы функций, которые работают с длинными строками способны исчерпать память. Поэтому нужно следить за использованием памяти и грамотно проектировать программу, чтобы сборщик мусора успевал ее нам чистить. Так же помощью оператора del можно вручную удалять не используемые переменные.

Статический веб-сервер

Пришло время реализовать статическую веб-страницу. На большинстве ресурсов код HTML страницы хранился прямо в коде сервера. Мы реализуем подход, когда наш сервер будет загружать HTML-код из файла, а также подгружать картинки по запросу браузера. Данный подход удобнее, так как позволяет в начале протестировать нашу HTML страницу в браузере, а затем просто ее загрузить в память ESP32. Для управления светодиодом мы будем использовать не ссылку на мнимую страницу, а создавать POST-запрос. Динамичность нашей страничке будет придавать наш веб-сервер, который будет заменять шаблонные выражения на реальные данные. В качестве шаблонного выражения в index.html используется #url_bulb#, который в зависимости от состояния светодиода меняется на название файл bulb_on.png или bulb_off.png

Итоговый вид нашей странички будет такой

 

Ниже представлен вид, который мы будем видеть на стороне нашего сервера.

Текст index.html

 <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title> Умная лампочка </title>  <style> .btn{ min-width: 100px; font-size: 12pt; } </style>  </head> <h3>Управление лампочкой</h3> <img src="#url_bulb#">  <br> <form action=/ method="post"> <button class="btn" type="submit" name="switch" value="1">Включить</button> </form>  <br> <form action=/ method="post"> <button class="btn" type="submit" name="switch" value="0">Отключить</button> </form>  </html>

А теперь текст нашего сервера который храниться в файле main.py

 from machine import Pin,reset import time import network import socket import io import gc  if __name__ == "__main__":     # инициализируем пин для управления светодиодом      led=Pin(2,Pin.OUT)     led_status=0     led.value(led_status)          #Настройка точки доступа     ap_if = network.WLAN(network.AP_IF)     ap_if.active(True)     ap_if.config(essid="MyPoint", password="12345678")     #Настройка проверки пароля точки доступа     #ap_if.config(authmode=network.AUTH_WPA_WPA2_PSK)     # Создание сервера     # ожидаем создание сети     while ap_if.active() == False:       pass     print('Connection successful')     print(ap_if.ifconfig())     # создаем сокет для прием сообщений     s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)     try:         s.bind(('', 80))     except OSError:         reset()     s.listen(5)     while True:         # ожидаем входящих сообщений         conn, addr = s.accept()         data=time.localtime()         print(f"Время {data[3]:02d}:{data[4]:02d}:{data[5]:02d}")         mem=gc.mem_free()         print(f"Осталось памяти начало {mem}")         #print(f"Got a connection from {str(adr)}")         request = conn.recv(1024)         # переводим из бинарного формата в строку         request=str(request)         # печатаем входящий запрос         print(request)         conn.send('HTTP/1.1 200 OKn')         conn.send('Content-Type: text/htmln')         conn.send('Connection: closenn')         # запрос на загрузка картинки         if request.find("bulb_off")>0:             with io.open("bulb_off.png","rb") as file:                 st=file.read()             conn.sendall(st)             conn.close()             del st             print(f"Осталось памяти в конце {mem}")             continue         elif request.find("bulb_on")>0:             with io.open("bulb_on.png","rb") as file:                 st=file.read()             conn.sendall(st)             conn.close()             del st             print(f"Осталось памяти в конце {mem}")             continue         # обработка событий кнопок и загрузки основной страницы         if request.find("switch=1")>0:             led_status=1         elif request.find("switch=0")>0:             led_status=0         led.value(led_status)         with io.open("index.html","r") as file:             st=file.read()         if led_status==1:             st=st.replace("#url_bulb#","bulb_on.png")         else:             st=st.replace("#url_bulb#","bulb_off.png")         conn.sendall(st)          conn.close()         del st         mem=gc.mem_free()         print(f"Осталось памяти в конце {mem}") 

Сервер настроен на создание точки доступа, к которой подключается клиент. Если требуется настроить защиту паролем, то необходимо раскомментировать строку ap_if.config(authmode=network.AUTH_WPA_WPA2_PSK)

Сервер ищет целевые слова в запросах и по ним открывает целевые файлы, отправляет их браузеру, включает и отключает лампочки.

При запуске в консоли будет текст запросов со стороны веб-браузера

Рассмотрим сообщения веб-браузера серверу.

При запросе страницы по адресу 192.168.4.1 придет следующее сообщение

Время 21:49:31
Осталось памяти начало 145968
b’GET / HTTP/1.1rnHost: 192.168.4.1rnConnection: keep-alivernUpgrade-Insecure-Requests: 1rnUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36rnAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7rnAccept-Encoding: gzip, deflaternAccept-Language: ru-RU,ru;q=0.9,en;q=0.8rnrn’
Осталось памяти в конце 143360

В ответ на это сервер отправить файл index.html

Загрузив файл index.html, браузер запросит фотографию лампочки, шаблон которой был заменен на название актуальной фотографии.

Время 21:49:31
Осталось памяти начало 143056
b’GET /bulb_off.png HTTP/1.1rnHost: 192.168.4.1rnConnection: keep-alivernUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36rnAccept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8rnReferer: http://192.168.4.1/rnAccept-Encoding: gzip, deflaternAccept-Language: ru-RU,ru;q=0.9,en;q=0.8rnrn’
Осталось памяти в конце 143056

Сервер находит название файла bulb_off, и открывает в бинарном виде файл bulb_off.png, после чего отправляет набор байтов веб-браузеру. Существует альтернативный способ отправить файл фотографии, через формат base64. Для этого фото в начале нужно конвертировать на сайте https://www.base64-image.de, а затем в атрибуте src вместо адреса картинки ввести код картинки сгенерированный сайтом. 

 <img src="data:image/jpeg;base64,/9j/4AAQSkZ...JRgABAQEASABIAAD//gA9Q1">

Код картинки получается достаточно длинным и дополнительно единовременно грузит нашу кучу. Для картинки размером в 10 кб размер строки у меня получился более 10 тысяч символов. Я не рекомендую использовать данный способ для крупных изображений, если только для очень маленьких картинок-иконок.

Продолжим рассматривать запросы браузера серверу

При нажатии на кнопку браузер будет посылать нам POST запрос на включение или отключение лампочки

Время 21:49:33
Осталось памяти начало 135536
b’POST / HTTP/1.1rnHost: 192.168.4.1rnConnection: keep-alivernContent-Length: 8rnCache-Control: max-age=0rnUpgrade-Insecure-Requests: 1rnOrigin: http://192.168.4.1rnContent-Type: application/x-www-form-urlencodedrnUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36rnAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7rnReferer: http://192.168.4.1/rnAccept-Encoding: gzip, deflaternAccept-Language: ru-RU,ru;q=0.9,en;q=0.8rnrnswitch=1
Осталось памяти в конце 128544

А ниже запрос на выключение

 Время 21:49:47
Осталось памяти начало 118816
b’POST / HTTP/1.1rnHost: 192.168.4.1rnConnection: keep-alivernContent-Length: 8rnCache-Control: max-age=0rnUpgrade-Insecure-Requests: 1rnOrigin: http://192.168.4.1rnContent-Type: application/x-www-form-urlencodedrnUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36rnAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7rnReferer: http://192.168.4.1/rnAccept-Encoding: gzip, deflaternAccept-Language: ru-RU,ru;q=0.9,en;q=0.8rnrnswitch=0
Осталось памяти в конце 111824

При этом если бы у нас были в форме еще <input> теги, то их атрибуты name и value отправились бы в одном запросе через амперсанд, что очень удобно. Следующий пример покажет отправку формы со всеми полями

Отправка нескольких данных из формы серверу

Расширим функционал нашей лампочки, добавив к кнопке включения функционал мигания, с возможностью задания интервала длительности. 

Мигание будет происходит по прерыванию таймера. Прерывание таймера — это один из способов осуществить опрос кнопок, датчиков, отправку сообщений. 

На HTML-странице все параметры которые должны быть заменены на реальные данные обрамлены решеткой #параметр#. Для оформления страницы были внедрены стили CSS, которые при небольшой доработки можно выделить в отдельный файл, аналогично файлу HTML. 

 <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title> Умная лампочка </title>  <style> .btn{ min-width: 100px; font-size: 12pt; }  .lbl{ font-size: 14pt; }  .switch_on { border: 5px inset;  width: 250px; padding: 5px 15px; /*text-align: center;*/ }  </style>  </head> <h3>Управление лампочкой</h3> <img src="#bulb_png#">  <br>  <form class="switch_on" action=/ method="post"> 	<p>Настройка работы</p> 	<p> 		<input type="radio" name="mode" value="on" #checked_on#> освещение 	</p> 	<p> 		<input type="radio" name="mode" value="blink" #checked_blink#> мигание 	</p> 	<p> 		Период мс:<input type="number" class="lbl" name="period" value="#period_value#" min="100" max="2000"> 	</p> 	 	<p> 		<button class="btn" type="submit">Включить</button> 	</p> </form>  <br> <form class="switch_on" action=/ method="post"> <button class="btn" type="submit" name="mode" value="off">Отключить</button> </form>   </html>

А текст нашего сервера стал более сложный. В нем добавился блок автоматической отправки фотографий, а параметры стали приниматься в словарь, который полностью соответствует структуре JSON.

 from machine import Pin,reset,Timer import time import network import socket import io import gc  # храним все настройки в словаре # для javascript формат словаря называется JSON param={     "mode":"off",      # режим работы     "mode_last":"off", # хранит предыдущий результат     "period":"1000"    # период мигания          } # обработчик прерываний def ledBlink():     global param     #global led     #print("прерывание")     if param["mode"]=="blink":         if led.value()==1:             led.value(0)         else:             led.value(1)      if __name__ == "__main__":     time.sleep(2)     # инициализируем пин для управления светодиодом     led=Pin(2,Pin.OUT)     if param["mode"]=="on":         led.value(1)     else:         led.value(0)     # инициализация таймера     tim1 = Timer(1)     tim1.init(period=int(param["period"]), mode=Timer.PERIODIC, callback=lambda t:ledBlink() )     #Настройка точки доступа     ap_if = network.WLAN(network.AP_IF)     ap_if.active(True)     ap_if.config(essid="MyPoint", password="12345678")     #Настройка проверки пароля точки доступа     #ap_if.config(authmode=network.AUTH_WPA_WPA2_PSK)     # Создание сервера     # ожидаем создание сети     while ap_if.active() == False:       pass     print('Connection successful')     print(ap_if.ifconfig())     # создаем сокет для прием сообщений     s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)     try:         s.bind(('', 80))     except OSError:         reset()     s.listen(5)     while True:         # ожидаем входящих сообщений         conn, addr = s.accept()         data=time.localtime()         print(f"Время {data[3]:02d}:{data[4]:02d}:{data[5]:02d}")         mem=gc.mem_free()         print(f"Осталось памяти начало {mem}")         #print(f"Got a connection from {str(adr)}")         request = conn.recv(1024)         # переводим из бинарного формата в строку         request=str(request)         # печатаем входящий запрос         print(request)         conn.send('HTTP/1.1 200 OKn')         conn.send('Content-Type: text/htmln')         conn.send('Connection: closenn')         #-------------------------------------------------------         # запрос на загрузка любой картинки по запросу         if request.find(".png")>0:             # делим строку на слова и оставляем только 2 элемент             # который является именем             name_img=request.split()[1]             # удаляем первый символ             name_img=name_img[1:]             try:                 with io.open(name_img,"rb") as file:                     st=file.read()                 conn.sendall(st)                 conn.close()                 del st                 print(f"Файл {name_img} отправлен");                 print(f"Осталось памяти в конце {mem}")                 continue             except OSError:                 print(f"Файл {name_img} не найден");         #-------------------------------------------------------         # обработка событий кнопок и загрузки основной страницы         if request.find("POST")>0:             # делим на слова и берем последнее             prm=request.split("\n")[-1]             print(prm)             # удаляем последний апостроф             prm=prm[:-1]             print(prm)             # делим на параметры             prm=prm.split("&")             for i in prm:                 temp=i.split("=")                 param[temp[0]]=temp[1]             print(param)         #----------Пункт 1--------------------------------------         # открываем заготовку html страницы          with io.open("index.html","r") as file:             st=file.read()         #----------Пункт 2------------------------------------         # заменяем шаблоны на актуальные названия файлов         if param["mode"]=="on":             st=st.replace("#bulb_png#","bulb_on.png")             param["mode_last"]="on"             print(f"режим вкл {param}")         elif param["mode"]=="blink":             st=st.replace("#bulb_png#","bulb_blink.png")             param["mode_last"]="blink"             print(f"режим мигания {param}")         else: # Режим выключен             st=st.replace("#bulb_png#","bulb_off.png")             print(f"режим выкл {param}")         # ставим указатель         if param["mode_last"]=="blink":             st=st.replace("#checked_on#","")             st=st.replace("#checked_blink#","checked")         else:                 st=st.replace("#checked_on#","checked")             st=st.replace("#checked_blink#","")                               # заменяем значение частоты на последнее текущее         st=st.replace("#period_value#",param["period"])             #----------Пункт 3-----------------------------------         # Отправляем данные браузеру         conn.sendall(st)          conn.close()         del st         mem=gc.mem_free()         print(f"Осталось памяти в конце {mem}")         #----------Пункт 4----------------------------------         # настраиваем режим работы нашего светодиода         if param["mode"]=="on":             led.value(1)             tim1.init(period=1000, mode=Timer.PERIODIC, callback=lambda t:ledBlink() )         elif param["mode"]=="blink":             tim1.init(period=int(param["period"]), mode=Timer.PERIODIC, callback=lambda t:ledBlink() )         else:             led.value(0)             tim1.init(period=1000, mode=Timer.PERIODIC, callback=lambda t:ledBlink() )          

Надеюсь, мой пример будет полезным людям, кто хочет начать разрабатывать IoT. 

Из минусов данного подхода хочется отметить, что страничка тормозит, данные обновляется только при обновлении страницы целиком. Кнопки иногда срабатывают не с первого раза, это связанно с частым вызовом сборщика мусора. Автоматическое обновление можно настроить вставив в раздел <head> тег <meta http-equiv=»refresh» content=»15″>. Однако проблему актуальных данных это не решает. При увеличении количества шаблонов, в которых необходимо делать замены, будет увеличиваться потребление памяти на операцию замены данных. Вручную менять шаблоны достаточно неудобно.

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

Прикладываю архив с листингам для удобства читателей.