Минимум библиотек — максимум понимания
В этой обзорной статье-конспекте мы будем работать с чистым модулем socket — без готовых библиотек вроде Microdot, urequests или MicroWebSrv2.
Почему мы отказываемся от готовых решений?
Понимание «под капотом». Когда вы знаете, как работает socket, любая сетевая библиотека становится прозрачной. Вы перестаёте быть заложником чужого кода и начинаете понимать, что на самом деле происходит в программе.
Экономия памяти. Для ESP8266 (особенно для модулей ESP-01S с 1 МБ Flash) каждый килобайт на счету. Готовая библиотека может весить 10–20 КБ, а чистый socket решает те же задачи с минимальными затратами.
Гибкость и независимость. Вы не привязаны к сторонним решениям и можете адаптировать код под любую, даже самую нестандартную задачу. Библиотека есть не всегда, а socket — есть всегда.
Этот материал — не просто статья, а личный конспект-справочник, к которому можно возвращаться в своих проектах. Мы пройдём путь от подключения к Wi-Fi до полноценного веб-сервера, работающего в неблокирующем режиме.
Сокет — это программный интерфейс для обмена данными между устройствами по сети. Если проводить аналогию, то серверный сокет — это дверь, в которую стучатся клиенты. Клиентский сокет — это человек, который стучится в дверь и ждёт ответа.
Сокет работает на уровне транспортного протокола — TCP или UDP. Он умеет:
- Устанавливать соединение (connect() для клиента, accept() для сервера).
- Отправлять и получать данные (send() и recv()).
- Работать в блокирующем или неблокирующем режиме.
- Настраивать таймауты и параметры через setsockopt().
Понимание этих механизмов позволяет адаптировать поведение под конкретную задачу и отлаживать самые сложные сетевые сценарии.
Что будет в этой статье
1. Фундамент: Wi-Fi и работа с сетью
Режимы STA_IF и AP_IF, управление соединением, кэширование NVS, полный сброс стека.
2. Ядро архитектуры: сокеты
Что такое сокет с точки зрения аналогий и кода. Блок-схемы работы сервера и клиента. Блокирующий и неблокирующий режимы — когда и какой использовать. Разберём setblocking(), settimeout() и их влияние на поведение программы. Разберём, что такое TIME_WAIT и почему перезапуск сервера часто приводит к ошибке «Address already in use».
4. Язык общения: протокол HTTP
Структура GET и POST-запросов, парсинг параметров из URL, чтение тела.
Начнём с фундамента — Wi-Fi и управления сетью.
Любая сетевая коммуникация начинается с подключения. Прежде чем ESP сможет принимать запросы или отправлять данные, он должен получить IP-адрес — либо от роутера (режим STA), либо раздать его сам (режим AP). Если провести аналогию, то ESP в мире сетей — как курьер. Он либо подключается к чужой сети (роутер), либо сам становится пунктом выдачи, создавая свою.
Две роли: клиент и точка доступа
Режим STA_IF (клиент) — «Я подключаюсь к чужой сети»
Когда ESP работает как клиент, он подключается к существующему роутеру и получает от него IP-адрес. Этот адрес может меняться — роутер выдаёт его по протоколу DHCP, и при переподключении адрес часто становится другим. ESP здесь — «гость» в чужой сети.
Если вы запускаете на ESP сервер, а IP-адрес меняется при каждой перезагрузке, управляющее устройство (телефон, другой ESP) не сможет найти сервер — оно будет стучаться в старую дверь, а сервер «переехал». Решение — зафиксировать IP-адрес (статический IP или DHCP-резервация).
import network import time # ====================================================== # 1. НАСТРОЙКА ДО ПОДКЛЮЧЕНИЯ # ====================================================== # 1.1. Задаём имя хоста (опционально) # Имя будет отображаться в списке устройств роутера. # Важно: установить ДО подключения. network.hostname("ESP32S3_HOME_SENSOR") # Максимум 32 символа # 1.2. Создаём клиентский интерфейс sta = network.WLAN(network.STA_IF) # 1.3. Назначаем статический IP (опционально) # Используйте, если: # - подключаетесь к телефонной точке доступа (DHCP-резервация недоступна) # - точно знаете, что IP свободен STATIC_IP = "192.168.4.99" # Фиксированный адрес SUBNET = "255.255.255.0" # Маска подсети GATEWAY = "192.168.4.1" # IP точки доступа (телефона) DNS = "8.8.8.8" # DNS-сервер sta.ifconfig((STATIC_IP, SUBNET, GATEWAY, DNS)) # 1.4. Включаем Wi-Fi интерфейс sta.active(True) # ====================================================== # 2. СБРОС КЭША NVS (опционально, но рекомендуется) # ====================================================== # ESP запоминает последнюю успешную сеть в NVS-памяти. # Чтобы гарантированно подключиться к нужной сети, # выполняем полный сброс стека. sta.disconnect() # Разрываем автоподключение к кэшированной сети sta.active(False) # Выключаем интерфейс time.sleep(1) # Даём время на сброс sta.active(True) # Включаем заново # ====================================================== # 3. ПОДКЛЮЧЕНИЕ К СЕТИ # ====================================================== sta.connect("Домашний_WiFi", "пароль") # Ждём подключения (блокирующий цикл) while not sta.isconnected(): time.sleep_ms(50) print(" Подключено! IP:", sta.ifconfig()[0]) print(" Имя хоста:", sta.config('dhcp_hostname')) # ====================================================== # 4. ОСНОВНОЙ ЦИКЛ — проверяем соединение # ====================================================== while True: # Проверяем, есть ли соединение if sta.isconnected(): # Соединение есть — выполняем действия # Например: отправка данных, приём команд, работа с сервером pass else: # Соединение потеряно — переподключаемся print(" Соединение потеряно. Переподключаюсь...") sta.disconnect() sta.active(False) time.sleep(1) sta.active(True) sta.connect("Домашний_WiFi", "пароль") # Ждём восстановления с таймаутом timeout = 10000 # 10 секунд start = time.ticks_ms() while not sta.isconnected(): if time.ticks_diff(time.ticks_ms(), start) > timeout: print(" Ошибка: таймаут переподключения") break time.sleep_ms(50) if sta.isconnected(): print(" Переподключено! IP:", sta.ifconfig()[0]) # Пауза для снижения нагрузки time.sleep_ms(10)
Что здесь происходит?
network.hostname() - задаёт имя ESP, которое видно в списке устройств роутера. Удобно идентифицировать устройство в сети. Но оно не делает устройство доступным по http://имя.local — для этого нужен mDNS, который на ESP работает не всегда стабильно.
sta.ifconfig() - назначает статический IP-адрес. ESP всегда доступен по одному адресу (решение проблемы «плавающего» IP).
sta.disconnect() + active(False/True) - полный сброс Wi-Fi стека. Очищает кэш NVS, гарантирует подключение к нужной сети. ESP запоминает последний успешный SSID и пароль во внутренней памяти (NVS). При включении ESP8266 может автоматически подключиться к этой сети, даже если вы вызвали connect() с другими параметрами.
while not sta.isconnected() Ожидание подключения, блокирующий цикл — без сети дальше не идём
Проверка isconnected() в цикле - мониторинг состояния соединения. При обрыве — автоматическое переподключение
Справочник: параметры wlan.config() - скрыть
Для тонкой настройки Wi-Fi интерфейса используется метод wlan.config(). Список доступных параметров зависит от платформы (ESP8266, ESP32), драйвера и версии MicroPython. Ниже приведены наиболее часто используемые.
# Установка параметра wlan.config(param_name=value) # Получение значения value = wlan.config('param_name')
Параметр | Описание | STA | AP |
mac | MAC-адрес (только чтение) | ✅ | ✅ |
ssid | Имя сети (для AP — имя точки доступа) | ✅ | ✅ |
channel | Wi-Fi канал (1–13). На некоторых портах только для AP | ✅ | ✅ |
hidden | Скрыть SSID (только для AP) | ❌ | ✅ |
security | Протокол шифрования (например, network.AUTH_WPA2_PSK) | ❌ | ✅ |
key | Пароль доступа (для AP) | ❌ | ✅ |
hostname | Имя хоста для DHCP/mDNS. Устаревший — используйте network.hostname() | ✅ | ❌ |
reconnects | Количество попыток переподключения (0 = нет, -1 = бесконечно) | ✅ | ❌ |
txpower | Мощность передатчика в дБм | ✅ | ✅ |
pm | Энергосбережение (PM_PERFORMANCE, PM_POWERSAVE, PM_NONE) | ✅ | ✅ |
protocol | Протокол 802.11 (только ESP32) | ✅ | ✅ |
bandwidth | Ширина канала (только ESP32) | ✅ | ✅ |
Режим AP_IF (точка доступа) — «Я сам создаю свою сеть»
В этом режиме ESP становится мини-роутером. Он создаёт собственную Wi-Fi-сеть с фиксированным именем (SSID) и сам раздаёт IP-адреса подключившимся устройствам. IP-адрес самой ESP в этом режиме постоянный — по умолчанию это 192.168.4.1. Он не меняется, пока вы сами его не измените через конфигурацию.
import network import time # ====================================================== # 1. НАСТРОЙКА ТОЧКИ ДОСТУПЫ # ====================================================== ap = network.WLAN(network.AP_IF) # Настраиваем параметры ДО включения ap.config(essid="ESP_Станция", password="12345678", channel=6) # Включаем точку доступа ap.active(True) # ====================================================== # 2. ОСНОВНОЙ ЦИКЛ — МОНИТОРИНГ СОСТОЯНИЯ # ====================================================== while True: # Проверяем, работает ли точка доступа if not ap.active(): print(" Точка доступа отключилась! Перезапускаем...") ap.active(False) time.sleep(1) ap.active(True) ap.config(essid="ESP_Станция", password="12345678") print(" Точка доступа перезапущена") # Проверяем, есть ли клиенты if ap.isconnected(): # Можно получить список подключённых клиентов (если поддерживается) print(" Есть подключённые клиенты") else: print(" Клиентов нет") # Пауза для снижения нагрузки time.sleep_ms(5000) # Проверяем каждые 5 секунд
Что здесь происходит?
ap.config(essid=..., password=...) - задаёт имя сети (SSID) и пароль. Клиенты будут подключаться по этим данным
ap.active(True) - включает точку доступа. Без этого ESP не создаст сеть.
ap.ifconfig()[0] - показывает IP-адрес ESP. Постоянный адрес для доступа к ESP.
ap.isconnected() - проверяет, есть ли подключённые клиенты. Полезно для мониторинга активности.
ap.active() - проверяет, включена ли точка доступа. Позволяет обнаружить сбой интерфейса.
Почему isconnected() для AP — это не совсем то же самое?
Для STA isconnected() проверяет, есть ли соединение с роутером. Это осмысленно и полезно.
Для AP isconnected() проверяет, подключены ли к точке доступа какие-то клиенты. Это может быть полезно, если вы хотите знать, есть ли активные устройства в вашей сети. Но это не проверка работоспособности самой точки доступа.
Если точка доступа «упала» (например, из-за сбоя), это можно проверить через ap.active() — возвращает True, если интерфейс включён.
Важные нюансы для точки доступа
1. disconnect() для AP использовать нельзя, вызовет ошибку!
# НЕПРАВИЛЬНО — вызовет ошибку ap.disconnect() # OSError: STA required # ПРАВИЛЬНО — только active(False/True) ap.active(False) time.sleep(1) ap.active(True)
2. Настройки AP сохраняются
Параметры, заданные через config() (SSID, пароль, канал), сохраняются даже после выключения интерфейса. При повторном active(True) они применяются автоматически.
3. Постоянный IP-адрес
В режиме AP ESP всегда имеет фиксированный IP-адрес (по умолчанию 192.168.4.1). Это удобно, потому что вы всегда знаете, как обратиться к ESP.
4. Переключение между AP и STA
Если нужно переключиться с AP на STA, просто выключите AP и включите STA:
# Переключение с AP на STA ap.active(False) # Выключаем точку доступа sta.active(True) # Включаем клиентский режим sta.connect("Домашний_WiFi", "пароль")
Ядро архитектуры: сокеты
И так, подключили ESP к Wi-Fi — получили IP-адрес и удерживаем соединение. Но IP-адрес — это как номер дома. Сам по себе он бесполезен, если нет двери, в которую можно постучаться, и человека за ней, который готов принять запрос и выдать информацию.
В мире сетей эту «дверь» называют сокетом (socket).
Сокет — это программный интерфейс для обмена данными между устройствами по сети. Если проводить аналогию:
Серверный сокет — это дверь со стороны приёмной. Сервер сидит внутри комнаты, слушает и ждёт, когда кто-то постучит. Он не знает заранее, кто придёт и когда. Его задача — открыть дверь, принять запрос, отдать информацию из своего архива и закрыть дверь.
Клиентский сокет — это человек с внешней стороны двери. Он стучит (или звонит в звонок), чтобы передать запрос. Ему нужно знать адрес (IP) и номер квартиры (порт). Если дверь открыли — он передаёт запрос и получает ответ.

import socket import time # ====================================================== # 1. СОЗДАЁМ КЛИЕНТСКИЙ СОКЕТ # ====================================================== # socket.AF_INET — IPv4 (используем обычные IP-адреса вида 192.168.1.1) # socket.SOCK_STREAM — TCP (надёжная передача с контролем доставки) client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # Устанавливаем таймаут на все операции (connect, send, recv) # Без таймаута клиент может зависнуть навсегда, если сервер не отвечает client.settimeout(5) # 5 секунд # ====================================================== # 2. ПОДКЛЮЧАЕМСЯ К СЕРВЕРУ # ====================================================== SERVER_IP = "192.168.4.1" # IP-адрес сервера (ESP в режиме AP) SERVER_PORT = 80 # Стандартный порт для HTTP try: # connect() устанавливает TCP-соединение с сервером # Передаём кортеж (IP, порт) client.connect((SERVER_IP, SERVER_PORT)) print(" Подключено!") except OSError as e: print(f" Не удалось подключиться: {e}") client.close() time.sleep(5) # Ждём 5 секунд и пробуем заново в основном цикле # return False # Если это функция # machine.reset() # Или перезагружаем ESP # ====================================================== # 3. ОТПРАВЛЯЕМ ЗАПРОС # ====================================================== # Формируем HTTP GET-запрос # Структура: # СТРОКА ЗАПРОСА: метод путь версия # ЗАГОЛОВКИ: ключ: значение (каждый с \r\n) # ПУСТАЯ СТРОКА: \r\n — конец заголовков request = ( "GET /data?temp=24.5&humidity=55 HTTP/1.1\r\n" # Путь с параметрами f"Host: {SERVER_IP}\r\n" # Обязательный заголовок "Connection: close\r\n" # Закрыть соединение после ответа "\r\n" # Пустая строка = конец заголовков ) try: # sendall() — отправляет ВСЕ данные # В отличие от send(), которая может отправить только часть данных # sendall() гарантирует, что все данные будут отправлены. # .encode() — превращает строку в байты (str → bytes), # потому что сокет работает только с байтами. client.sendall(request.encode()) print(" Запрос отправлен") except OSError as e: print(f" Ошибка отправки: {e}") client.close() # return False, если это функция # ====================================================== # 4. ПОЛУЧАЕМ ОТВЕТ # ====================================================== try: response = b"" # Пустой байтовый массив для накопления ответа # Читаем данные в цикле, пока соединение не закроется # Почему в цикле? Потому что ответ может быть больше 1024 байт. # recv(1024) читает максимум 1024 байта за один раз. # Если ответ больше — нужно читать несколько раз. while True: chunk = client.recv(1024) # Читаем кусок до 1024 байт if not chunk: # Пустой ответ = сервер закрыл соединение break response += chunk # Накопляем данные # .decode() превращает байты обратно в строку print(response.decode())# ПРИ НЕОБХОДИМОСТИ ПАРСИМ ОТВЕТ ПАРСЕР В ОТДЕЛЬНОМ КОДЕ except OSError as e: print(f" Ошибка получения: {e}") # ====================================================== # 5. ЗАКРЫВАЕМ СОКЕТ # ====================================================== # close() — освобождает ресурсы сокета # Обязательно закрывать, чтобы не занимать память client.close()
Три режима работы сокета
Блокирующий (settimeout(None)) — ждёт вечно. Опасно для клиента — может зависнуть навсегда.
Неблокирующий (settimeout(0)) — не ждёт вообще. Используется для параллельных задач.
С таймаутом (settimeout(5)) — ждёт 5 секунд, потом выбрасывает исключение. Самый безопасный и рекомендуемый режим для клиента.
Как проверить соединение с сервером?
В MicroPython у сокета нет метода isconnected(). Состояние TCP-соединения проверяется только через исключения — если при отправке или получении данных возникает OSError, значит, соединение потеряно.
Для длительных соединений можно отправлять периодические keepalive-пакеты, чтобы сервер и роутер не закрывали сокет из-за бездействия. В некоторых сборках доступна опция SO_KEEPALIVE, но она работает не всегда предсказуемо.
Практическое правило: для большинства IoT-сценариев (отправка показаний, опрос команд) проще открывать соединение, выполнять операцию и сразу закрывать — это надёжнее и не требует поддержания состояния.
Какие ошибки можно поймать при connect()?
В MicroPython все сетевые ошибки приходят как OSError с числовым кодом (errno).
Основные ошибки подключения
116 ETIMEDOUT - таймаут — сервер не отвечает в течение заданного времени.
111 ECONNREFUSED - сервер отверг соединение — порт закрыт или ничего не слушает.
104 ECONNRESET - соединение сброшено — сервер разорвал связь.
103 ECONNABORTED - соединение прервано — проблема на стороне сервера.
113 EHOSTUNREACH - хост недоступен — сеть не может достучаться до адреса.
119 EINPROGRESS - соединение уже устанавливается (при нулевом таймауте).
118 EALREADY - операция уже выполняется.
128 ENOTCONN - сокет не подключён.
send() может отправить только часть данных (если буфер заполнен). sendall() гарантирует отправку всех данных, повторяя попытку автоматически. Для HTTP-запросов всегда используйте sendall().
Что делать с ответом?
Полученный HTTP-ответ содержит:
Строку статуса — код ответа (200 — успешно, 404 — не найдено, 500 — ошибка сервера).
Заголовки — служебная информация.
Тело — сами данные (обычно техт, JSON или HTML).
После получения ответа нужно:
Извлечь статус и проверить, успешный ли запрос.
Если status_code == 200 — извлечь данные из тела.
Если статус другой — обработать ошибку (повторить попытку, сообщить пользователю). Вот функция, которая парсит HTTP-ответ и извлекает тело:
def parse_http_response(response_bytes): """ Парсит HTTP-ответ и возвращает (статус, заголовки, тело) """ # 1. Превращаем байты в строку response_str = response_bytes.decode('utf-8') # 2. Находим конец заголовков (двойной перенос строки) header_end = response_str.find('\r\n\r\n') # 3. Извлекаем строку статуса и заголовки status_line = response_str[:response_str.find('\r\n')] headers = response_str[:header_end] body_str = response_str[header_end + 4:] # Тело как строка # 4. Парсим статус parts = status_line.split(' ') http_version = parts[0] # "HTTP/1.1" status_code = int(parts[1]) # 200 status_text = ' '.join(parts[2:]) # "OK" # 5. Парсим заголовки в словарь headers_dict = {} for line in headers.split('\r\n')[1:]: # Пропускаем первую строку (статус) if ': ' in line: key, value = line.split(': ', 1) headers_dict[key] = value return status_code, status_text, headers_dict, body_str
Язык общения: протокол HTTP
HTTP — это текстовый протокол. Клиент отправляет строку, сервер её читает, парсит и отвечает. Рассмотрим два основных метода запросов GET и POST.
GET-запрос: данные в URL
GET передаёт параметры в строке запроса (после ?) или может быть без параметров, просто указав путь к ресурсу.
GET /data?temp=24.5&humidity=55 HTTP/1.1
Host: 192.168.4.1
Connection: close
/data — путь (ресурс).
?temp=24.5&humidity=55 — параметры (ключ=значение, разделяются &).
Пустая строка (\r\n\r\n) — конец заголовков.
# БЕЗ параметров
request = "GET /data HTTP/1.1\r\nHost: 192.168.4.1\r\n\r\n"
# С параметрами
request = "GET /data?temp=24.5&humidity=55 HTTP/1.1\r\nHost: 192.168.4.1\r\n\r\n"
Когда использовать GET:
Получение данных (чтение).
Передача небольшого объёма параметров.
Нет чувствительных данных (пароли, токены).
POST-запрос: данные в теле
POST передаёт данные в теле запроса, после всех заголовков.
POST /api/data HTTP/1.1
Host: 192.168.4.1
Content-Type: application/json
Content-Length: 35
Connection: close
{"temp": 24.5, "humidity": 55}
Что здесь?
Тело начинается после пустой строки (\r\n\r\n).
Content-Length — обязателен! Указывает длину тела в байтах.
Content-Type — указывает формат (application/json, text/plain, application/x-www-form-urlencoded).
import json data = {"temp": 24.5, "humidity": 55} body = json.dumps(data) # Строка JSON body_bytes = body.encode() # Байты для подсчёта длины request = ( "POST /api/data HTTP/1.1\r\n" "Host: 192.168.4.1\r\n" "Content-Type: application/json\r\n" f"Content-Length: {len(body_bytes)}\r\n" # В БАЙТАХ! "Connection: close\r\n" "\r\n" f"{body}" )
Ключевые моменты:
GET без параметров — просто запрос данных: GET /data HTTP/1.1.
GET с параметрами — параметры после ?: /data?key1=value1&key2=value2.
POST — данные в теле после \r\n\r\n.
Content-Length — всегда в байтах, не в символах: len(body.encode()).
Content-Type — указывает формат тела: application/json, text/plain.
Для кириллицы len(body) и len(body.encode()) — разные числа!!!
Серверный сокет — ESP принимает запросы
Теперь ESP выступает в роли сервера — он сам создаёт «дверь», слушает и ждёт, пока кто-то постучит. Это классический сценарий для веб-интерфейса, API-эндпоинтов или прямого управления устройством.
Аналогия.
Представьте, что вы открыли небольшую мастерскую. Вы вешаете вывеску с адресом (IP) и номером кабинета (порт). Теперь клиенты знают, где вас найти. Вы сидите в кабинете и ждёте, пока кто-то постучит (accept). Когда стук раздаётся — вы открываете дверь (получаете соединение), принимаете заказ (читаете запрос), выдаёте результат (отправляете ответ) и закрываете дверь (close). После этого вы снова готовы слушать следующего клиента.
Важный нюанс: одна «дверь» (серверный сокет) может обслужить тысячи клиентов последовательно. Каждый раз, когда кто-то стучит, вы открываете дверь, общаетесь и снова закрываете. Но сама дверь остаётся на месте и продолжает слушать.

import socket import time # ====================================================== # 1. СОЗДАЁМ СЕРВЕРНЫЙ СОКЕТ # ====================================================== # socket.AF_INET — IPv4 # socket.SOCK_STREAM — TCP server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # ====================================================== # 2. НАСТРАИВАЕМ ПЕРЕИСПОЛЬЗОВАНИЕ ПОРТА (КРИТИЧНО!) # ====================================================== # Без этой опции при перезапуске сервера вы получите: # OSError: [Errno 98] EADDRINUSE — "Address already in use" # Почему? После закрытия сокета порт может быть в состоянии TIME_WAIT # до 30-60 секунд. SO_REUSEADDR разрешает занять порт сразу. server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # ====================================================== # 3. ПРИВЯЗЫВАЕМСЯ К ПОРТУ # ====================================================== SERVER_PORT = 80 # Стандартный порт для HTTP # '0.0.0.0' — слушаем на всех интерфейсах (и STA, и AP) server.bind(('0.0.0.0', SERVER_PORT)) # ====================================================== # 4. НАЧИНАЕМ СЛУШАТЬ # ====================================================== # параметр=1 — сколько соединений может ждать в очереди server.listen(1) # ====================================================== # 5. ВКЛЮЧАЕМ НЕБЛОКИРУЮЩИЙ РЕЖИМ (КЛЮЧЕВОЙ МОМЕНТ!) # ====================================================== # Без этого accept() будет блокировать выполнение программы, # пока не придёт клиент. В неблокирующем режиме accept() # возвращает управление сразу — если клиента нет, # выбрасывается исключение OSError: 11 (EAGAIN) server.setblocking(False) # ====================================================== # 6. ОСНОВНОЙ ЦИКЛ # ====================================================== while True: try: # ====================================================== # 6.1 ПРИНИМАЕМ СОЕДИНЕНИЕ # ====================================================== # accept() возвращает кортеж: (новый сокет для общения, адрес клиента) conn, addr = server.accept() # ====================================================== # 6.2 ЧИТАЕМ ЗАПРОС # ====================================================== # Читаем данные от клиента request_bytes = conn.recv(1024) request_str = request_bytes.decode('utf-8') # ====================================================== # 6.3 ПАРСИМ ЗАПРОС (упрощённо) # ====================================================== # Извлекаем первую строку: "GET /data?temp=24 HTTP/1.1" first_line = request_str.split('\r\n')[0] parts = first_line.split(' ') if len(parts) >= 3: method = parts[0] # GET или POST path = parts[1] # /data?temp=24 version = parts[2] # HTTP/1.1 # ====================================================== # 6.4 ФОРМИРУЕМ ОТВЕТ # ====================================================== # Простейшая логика: если путь / — отдаём приветствие if path == '/': body = "Hello from ESP!".encode('utf-8') status = "200 OK" content_type = "text/plain" else: body = f"Path: {path}".encode('utf-8') status = "200 OK" content_type = "text/plain" # Формируем HTTP-ответ response = ( f"HTTP/1.1 {status}\r\n" f"Content-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n" # len(body) — байты! "Connection: close\r\n" "\r\n" ).encode() + body # Заголовки (байты) + тело (байты) else: # Некорректный запрос body = b"Bad Request" response = ( "HTTP/1.1 400 Bad Request\r\n" f"Content-Length: {len(body)}\r\n" "Connection: close\r\n" "\r\n" ).encode() + body # ====================================================== # 6.5 ОТПРАВЛЯЕМ ОТВЕТ # ====================================================== conn.sendall(response) # ====================================================== # 6.6 ЗАКРЫВАЕМ СОЕДИНЕНИЕ # ====================================================== conn.close() except OSError as e: # Код 11 = EAGAIN — это НЕ ОШИБКА! # Это сигнал "нет новых подключений" в неблокирующем режиме if e.args[0] == 11: # Ничего не делаем — просто продолжаем цикл pass else: # Реальная ошибка — выводим print(f" Ошибка: {e}") # ====================================================== # 6.7 ФОНОВЫЕ ЗАДАЧИ (опрос датчиков, управление реле...) # ====================================================== # Здесь вы можете опрашивать датчики, мигать светодиодом, # проверять кнопки — всё это выполняется параллельно с сервером! # Небольшая пауза для снижения нагрузки на процессор time.sleep_ms(10)
Что здесь происходит?
socket.socket() - создаёт сокет, определяет тип соединения (IPv4, TCP).
setsockopt(SO_REUSEADDR) - разрешает переиспользовать порт, без этого при перезапуске сервера получите EADDRINUSE.
bind() - привязывает сокет к порту, без этого сервер не будет «слышать» порт.
listen() - начинает слушать порт, переводит сокет в режим приёма соединений.
setblocking(False) - включает неблокирующий режим, позволяет выполнять фоновые задачи в цикле.
accept() - принимает входящее соединение, в неблокирующем режиме возвращает OSError: 11, если клиентов нет.
recv() - читает данные от клиента.
sendall() - отправляет ответ, гарантирует отправку всех данных.
close() Закрывает соединение и освобождает ресурсы.
Почему SO_REUSEADDR критичен?
SO_REUSEADDR — это системная настройка сокета, которая говорит ОС: «разреши привязать этот IP:порт, даже если он недавно использовался».
Что происходит без SO_REUSEADDR (почему падает EADDRINUSE)?
Когда ты делаешь s.bind((ip, PORT)), ОС проверяет: «этот адрес сейчас кем‑то занят?». Если да — возвращает ошибку EADDRINUSE.
Но «занят» может означать не «сейчас активно используется», а «был занят совсем недавно». Это связано с состоянием TCP TIME_WAIT.
Как работает TIME_WAIT
Ты останавливаешь сервер (перезагрузка ESP32, выключение питания, вылет скрипта). ОС закрывает соединение, но оставляет порт в состоянии TIME_WAIT на 30–120 секунд (зависит от стека). Это нужно, чтобы ОС могла корректно обработать «запоздавшие» пакеты из старого соединения. Ты сразу загружаешь новый код и делаешь bind() на тот же порт — ОС видит, что порт всё ещё «занят» (в TIME_WAIT) и выдаёт EADDRINUSE, хотя твой старый сервер уже не работает.
На ESP32 это особенно заметно: ты перезагрузил плату, код стартует, а порт ещё «в тени» из‑за TIME_WAIT. Без SO_REUSEADDR сервер не запустится, пока не истечёт таймаут ОС.
Если даже с SO_REUSEADDR сервер не запускается после перезагрузки (ошибка 98), сделайте полный сброс Wi-Fi стека (как показано выше) — это освобождает сетевые ресурсы ESP.
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
Эта строка говорит ОС: «если порт в TIME_WAIT — всё равно разреши мне на него привязаться».
В результате:
Ты можешь перезапускать ESP32‑сервер почти мгновенно. Не нужно ждать 1–2 минуты, пока ОС освободит порт. При разработке это экономит кучу времени.
Важное уточнение для MicroPython на ESP32
Хотя SO_REUSEADDR сильно помогает, он не гарантирует мгновенное освобождение порта. Всё зависит от того, как стек TCP/IP в конкретной сборке MicroPython обрабатывает TIME_WAIT:
В некоторых случаях SO_REUSEADDR позволяет bind() пройти сразу, даже если порт в TIME_WAIT.
В других — порт всё равно может быть «недоступен» короткое время, и тогда помогает перезагрузка ESP32.
Если порт реально занят другим работающим процессом (например, на ESP32 уже крутится веб‑сервер), SO_REUSEADDR не поможет. Ошибка EADDRINUSE останется, потому что порт действительно используется. SO_REUSEADDR спасает только от ситуации «порт был занят недавно, сейчас никто не использует, но ОС всё ещё считает его занятым из‑за TIME_WAIT».
Блокирующий и Неблокирующий сервер - server.setblocking(True / False)
Блокирующий — если сервер единственная задача.
Неблокирующий — если нужно одновременно опрашивать датчики, управлять реле, мигать светодиодом.
Как сервер парсит GET и POST (упрощённо)
# Парсим первую строку запроса first_line = request_str.split('\r\n')[0] # "GET /data?temp=24 HTTP/1.1" method, path, version = first_line.split(' ') if method == "GET": # Извлекаем параметры из path if '?' in path: path_only, query = path.split('?', 1) # path_only="/data", query="temp=24" # Парсим query: temp=24&humidity=55 params = {} for pair in query.split('&'): if '=' in pair: key, value = pair.split('=', 1) params[key] = value # Используем params['temp'], params.get('humidity', 'не указано') elif method == "POST": # Ищем тело после \r\n\r\n header_end = request_str.find('\r\n\r\n') if header_end != -1: body = request_str[header_end + 4:] # Проверяем Content-Type if 'application/json' in request_str: import json data = json.loads(body) # Используем data['temperature'], data['humidity'] else: # plain text или form-data print("Тело:", body)
Основные ошибки сервера
11 EAGAIN - нет новых подключений (неблокирующий режим).
98 EADDRINUSE - порт уже занят, использовать SO_REUSEADDR.
104 ECONNRESET - клиент разорвал соединение.
107 ENOTCONN - сокет не подключён, проверить состояние.
128 ENOTCONN - сокет не подключён.
Итог по серверу
Сервер создаётся один раз — до цикла.
SO_REUSEADDR обязателен — без него перезапуск приведёт к EADDRINUSE.
Неблокирующий режим — для серверов с фоновыми задачами.
OSError: 11 (EAGAIN) — это нормально! Это сигнал «нет клиентов».
Каждое соединение — это новый сокет, который нужно закрывать после ответа.
sendall() гарантирует отправку всего ответа.
В этом конспекте мы разобрали основную суть клиент-серверной архитектуру на ESP — от подключения к Wi-Fi до обработки HTTP-запросов в неблокирующем режиме. Думаю, что можно сказать, что сокет — это не страшно, а просто «дверь» для обмена данными. Рассмотрели, почему SO_REUSEADDR спасает от EADDRINUSE, почему Content-Length нужно считать в байтах, и почему OSError: 11 — это нормально.
Статья задумывалась как личный конспект-справочник, к которому можно возвращаться в своих проектах. Надеюсь, она выполнила свою задачу.
В следующих статьях я планирую рассмотреть:
Экосистема протоколов: TCP или UDP или ESP-NOW. Где наш сокет в модели OSI и как выбрать протокол для своей задачи.
Управление устройствами: Polling, P2P, WebSockets, MQTT. Как управлять десятками ESP и организовывать двустороннюю связь без внешнего сервера.

