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