Введение
Примечание о переводе: это русскоязычная версия книги. Оригинал на английском языке является основным, поэтому при расхождениях ориентируйтесь на него. Код, сообщения в логах и тексты интерфейса оставлены на английском языке, так же как в самих проектах.
Цель этой книги — дать подробное руководство по началу работы с языком программирования Rust на устройствах ESP32-C3 от Espressif.
Книга проведёт вас через создание приложения на Rust в режиме no_std с использованием esp-hal. По ходу дела мы будем работать с разной аппаратной периферией. Подробнее о приложении, которое мы будем создавать, рассказано в главе Обзор проекта.
Мы предполагаем, что вы уже знакомы с Rust; подробности см. в главе Предварительные требования.
Если у вас возникнут технические вопросы или проблемы, вы можете присоединиться к сообществу esp-rs в Matrix. Сообщество открыто для всех.
Как пользоваться этой книгой
Эта книга рассчитана на чтение от начала до конца. Последующие главы опираются на понятия, введённые в предыдущих главах, а ранние главы могут опускать детали, которые раскрываются позже.
Исходный код приложения, которое мы будем создавать, находится в каталоге no-std-training/project этого репозитория.
Принятые в книге обозначения
В книге используются значки для выделения разных видов информации:
- ✅ Призыв к действию.
- ⚠️ Предупреждения и сведения, которым нужно уделить особое внимание.
- 🔎 Подробности, которые углубляют тему, но не обязательны для понимания дальнейшего материала.
- 💡 Подсказки, которые могут помочь при выполнении упражнений.
Пример примечания: примечания, подобные этому, содержат полезную информацию
В некоторых файлах на Rust вы можете встретить якорные комментарии:
// ANCHOR: test
let foo = 1;
...
// ANCHOR_END: test
Якорные комментарии можно игнорировать: они нужны только для того, чтобы выделять фрагменты кода в книге. См. документацию mdBook.
Повторное использование материалов
Эта книга распространяется под следующими лицензиями:
- Код, содержащийся в книге, распространяется на условиях как лицензии MIT, так и Apache License v2.0.
- Текст, изображения и диаграммы в книге распространяются на условиях лицензии Creative Commons CC-BY-SA v4.0.
Если вы хотите использовать наш текст или изображения в своей работе, вы должны:
- Указать соответствующее авторство (например, упомянуть эту книгу на слайде и привести ссылку на нужную страницу).
- Привести ссылку на лицензию CC-BY-SA v4.0.
- Указать, если вы каким-либо образом изменили материал, и опубликовать свои изменения на тех же условиях лицензии.
Предварительные требования
В этой главе описаны знания, оборудование и программное обеспечение, которые необходимы для прохождения курса.
Rust
Требуется определённый опыт работы с Rust и программирования в целом. Этот курс не ставит целью научить программированию или самому языку Rust, поскольку для этих тем уже существует много качественных материалов.
Если вы совсем не знакомы с языком, мы рекомендуем сначала прочитать The Rust Book (на английском языке). Также можно заранее ознакомиться с The Rust on ESP Book, хотя это и не обязательно.
Тулчейн
Требуется последняя стабильная версия тулчейна Rust, а также нужно установить целевую платформу riscv32imc-unknown-none-elf.
Rust следует устанавливать с помощью официального установщика, который находится по адресу https://rustup.rs/. Необходимые тулчейн и целевую платформу можно установить командой:
rustup toolchain install stable --target riscv32imc-unknown-none-elf
Дополнительное ПО
Для загрузки прошивки на устройство мы также будем использовать espflash. Его можно установить с помощью Cargo:
cargo install espflash --locked
Оборудование
В этом курсе мы будем использовать отладочную плату ESP32-C3-DevKit-RUST-2. Можно использовать и другие платы, но эта плата содержит датчики и другие устройства, которые используются нашим приложением, поэтому для наилучших результатов настоятельно рекомендуется использовать именно её.
Проектные файлы этой отладочной платы (схемы и т. п.) доступны в репозитории esp-rs/esp-rust-board. Плату можно купить на AliExpress и Mouser, см. раздел Где купить.
Создание приложения-регистратора данных
В следующих главах мы с нуля создадим простое приложение-регистратор данных и будем постепенно добавлять в него новые возможности.
Прежде чем начать, мы кратко опишем приложение в [Обзоре проекта].
Обзор проекта
В этом курсе мы создадим простое приложение-регистратор данных. Оно подключится к сети Wi-Fi, будет считывать данные с датчика и отправлять их на брокер MQTT.
Цель курса — получить приложение, которое похоже на «настоящий» проект и охватывает, по нашему мнению, важные темы, но при этом не требует писать тысячи строк кода, чтобы получить готовый результат.
- Сначала в настройке проекта мы проведём вас через инициализацию проекта: сгенерируем новый шаблонный проект и объясним его составные части.
- В чтении данных с датчика мы начнём с инициализации устройства, чтобы плата была правильно настроена и готова к работе. Затем мы будем считывать данные с подключённого датчика температуры и влажности по шине I2C.
- В подключении к Wi-Fi мы рассмотрим основы подключения к локальной сети Wi-Fi и установления соединения с Интернетом.
- Когда датчик и Wi-Fi будут инициализированы, в публикации данных в брокер MQTT мы подключимся к брокеру MQTT и отправим на него данные датчика.
- В Wi-Fi-провизионинге мы вернёмся к теме Wi-Fi и покажем, как настроить Wi-Fi-провизионинг. Это позволит подключать устройство к произвольным сетям без необходимости жёстко прописывать учётные данные сети в прошивке.
- Наконец, мы расскажем, как обновлять устройства в полевых условиях с помощью обновлений по воздуху (OTA).
Настройка проекта
Создание проекта
Начнём с создания минимального проекта в качестве отправной точки. Для таких случаев мы предоставляем инструмент esp-generate. Его можно установить командой:
cargo install --locked esp-generate
После установки esp-generate создадим пустой проект:
esp-generate --headless --chip=esp32c3 -o unstable-hal -o embassy no_std-training
Поскольку мы работаем с отладочной платой ESP32-C3-DevKit-RUST-2, выбираем чип ESP32-C3. Мы будем использовать асинхронную поддержку через Embassy, поэтому нужны обе опции: embassy и unstable.
Теперь в текущем каталоге должен появиться каталог no_std-training/.
Структура проекта
Сгенерированный проект должен выглядеть так:
no_std-training/
├─ .cargo/
│ └─ config.toml
├─ .clippy.toml
├─ build.rs
├─ Cargo.toml
├─ rust-toolchain.toml
└─ src/
├─ bin/
│ └─ main.rs
└─ lib.rs
Рассмотрим, за что отвечает каждый из этих файлов:
.cargo/config.tomlсодержит конфигурацию Cargo.- Указана целевая платформа, в данном случае
riscv32imc-unknown-none-elf. - Переданы флаги компилятора Rust, которые принудительно включают frame pointers. Это необходимо для корректной работы бэктрейсов. Если вы не используете крейт
esp-backtrace, это не требуется. - Кроме того, мы настраиваем espflash как раннер Cargo для проекта.
- Указана целевая платформа, в данном случае
.clippy.tomlсодержит конфигурацию Clippy, линтера для кода на Rust.build.rsнастраивает компилятор Rust так, чтобы подключались необходимые скрипты компоновщика.- Это можно было бы задать и в
.cargo/config.toml, но такой подход даёт более понятные диагностические сообщения при ошибках сборки.
- Это можно было бы задать и в
Cargo.toml— манифест Cargo, в котором хранятся метаданные проекта, зависимости и другая информация.rust-toolchain.tomlзадаёт, какой тулчейн Rust использовать, а также указывает нужные компоненты и целевую платформу.src/lib.rsсодержит библиотечный код приложения. Сейчас он пуст, но будет наполнен в следующих главах.src/bin/main.rs— точка входа в приложение. Здесь будет находиться большая часть логики.
Зависимости
Сгенерированный Cargo.toml уже содержит ряд зависимостей. Их назначение кратко описано ниже:
esp-hal— слой абстракции аппаратного обеспечения (HAL). Предоставляет доступ к встроенной периферии через высокоуровневые API.esp-rtos— поддержка среды выполнения, необходимая для запуска асинхронного кода поверхesp-hal.esp-bootloader-esp-idf— поддержка загрузчика ESP-IDF.embassy-executor— асинхронный исполнитель для встраиваемых устройств. Позволяет запускать одну или несколько асинхронных задач.embassy-time— асинхронная работа со временем: задержки и таймауты.critical-section— критические секции для встраиваемых устройств.static_cell— статически выделяемая ячейка, которая инициализируется во время выполнения.
Дополнительные зависимости будут добавлены и в последующих главах.
Минимальное приложение
Файл src/bin/main.rs содержит шаблонный код минимального приложения:
#![no_std]
#![no_main]
#![deny(
clippy::mem_forget,
reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
holding buffers for the duration of a data transfer."
)]
#![deny(clippy::large_stack_frames)]
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
loop {}
}
// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику ESP-IDF.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32c3/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();
#[allow(
clippy::large_stack_frames,
reason = "it's not unusual to allocate larger buffers etc. in main"
)]
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt =
esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
// TODO: запустить задачи
let _ = spawner;
loop {
Timer::after(Duration::from_secs(1)).await;
}
// для вдохновения посмотрите примеры по адресу https://github.com/esp-rs/esp-hal/tree/main/examples
}
В целом код понятен, однако есть несколько интересных моментов, которые мы кратко объясним.
Application Description
Загрузчик ESP-IDF ожидает, что структура Application Description находится по определённому адресу. Если её не добавить, вы получите запутанные ошибки загрузчика.
Application Description создаётся с помощью такого вызова макроса:
#![allow(unused)]
fn main() {
esp_bootloader_esp_idf::esp_app_desc!();
}
Больше ничего делать не нужно. Подробнее см. в документации ESP-IDF.
Макрос #[esp_rtos::main]
Макрос #[esp_rtos::main] определяет точку входа приложения, обычно это функция main:
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
// ...
}
Этот макрос создаёт новый экземпляр исполнителя Embassy Executor и запускает функцию main как асинхронную задачу.
Чтение данных с датчика
В этой главе мы инициализируем плату, настроим шину I2C и будем считывать температуру и влажность с датчика SHTC3 на отладочной плате ESP32-C3-DevKit-RUST-2. Каждое успешное измерение выводится с помощью крейта log, а ошибки датчика сообщаются сообщениями уровня error.
Готовый код для этой главы находится в project/part1/.
Инициализация
В начале приложения настраивается логирование, конфигурируется чип и запускается поддержка среды выполнения, которая нужна остальной части приложения. Мы рассмотрим каждый шаг отдельно.
Обработчик паники
В приложении no_std необходимо предоставить обработчик паники. В минимальном приложении из предыдущей главы использовался ручной #[panic_handler], но в этой главе мы будем использовать esp-backtrace:
use esp_backtrace as _;
Feature panic-handler крейта esp-backtrace предоставляет обработчик паники за нас, а также даёт полезную поддержку бэктрейсов, когда что-то идёт не так.
Логгер
Сначала инициализируем логгер:
esp_println::logger::init_logger_from_env();
Это позволяет использовать стандартные макросы log. В этой главе мы используем info! для вывода успешных показаний температуры и влажности, а error! — для сообщений о сбоях при запуске или чтении измерения.
init_logger_from_env() настраивает логирование по переменной окружения ESP_LOG. Формат значения похож на формат фильтров RUST_LOG из env_logger. Переменную можно задать при сборке или запуске приложения:
ESP_LOG=info cargo run --release
Распространённые глобальные уровни: error, warn, info, debug, trace и off. Например, ESP_LOG=warn показывает только предупреждения и ошибки, а ESP_LOG=debug включает также сообщения уровня debug.
Уровень логирования по умолчанию можно задать и в .cargo/config.toml:
[env]
ESP_LOG = "info"
С такой конфигурацией cargo run --release будет использовать ESP_LOG=info, если вы не переопределите значение в оболочке.
Также можно фильтровать отдельные модули:
ESP_LOG=warn,no_std_training=info cargo run --release
Эта команда включает логи уровня info для нашего приложения, а остальные модули оставляет на уровне warn.
esp_hal::init
esp_hal::Config описывает системную конфигурацию, которая применяется при инициализации HAL. Это тип с атрибутом non_exhaustive, поэтому мы создаём его с помощью Config::default(), а затем изменяем нужные параметры. В нашем приложении мы используем with_cpu_clock(CpuClock::max()), чтобы запросить максимальную тактовую частоту ЦП:
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
esp_hal::init применяет эту конфигурацию. Он настраивает тактовую частоту ЦП и сторожевой таймер (watchdog), а затем возвращает периферию и тактовые сигналы, необходимые HAL.
Значение, важное для следующих шагов, — peripherals. Оно содержит отдельное значение Rust для каждого аппаратного блока и каждого вывода. Когда мы позже создадим драйверы, мы передадим эти значения в конструкторы драйверов. Именно передача владения позволяет esp-hal предотвращать случайное двойное использование оборудования: как только peripherals.I2C0 будет передан в драйвер I2C (см. ниже), никакой другой драйвер не сможет использовать тот же блок I2C.
esp_rtos::start
Приложение использует асинхронный Rust через Embassy. esp_rtos::start запускает планировщик, который используют esp-rtos и Embassy. Ему нужны аппаратный таймер и программное прерывание:
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt =
esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
Таймер используется для асинхронных операций, основанных на времени, таких как Timer::after(...). Программное прерывание используется средой выполнения, чтобы пробуждать задачи.
Драйверы
Далее создаём драйвер шины I2C для датчика:
let sda = peripherals.GPIO10;
let scl = peripherals.GPIO8;
let i2c = I2c::new(peripherals.I2C0, Config::default())
.expect("Failed to create I2C bus")
.with_sda(sda)
.with_scl(scl)
.into_async();
let mut sht = shtc3(i2c);
I2c::new принимает владение периферией I2C и её конфигурацией. Плата ESP32-C3-DevKit-RUST-2 подключает датчик SHTC3 к GPIO10 (SDA) и GPIO8 (SCL), поэтому мы подключаем эти выводы с помощью методов with_*.
Многие драйверы esp-hal следуют такому шаблону:
- Создать драйвер с аппаратной периферией и конфигурацией.
- Подключить выводы или дополнительные настройки с помощью методов
with_*, напримерwith_sdaиwith_scl. - Выбрать режим выполнения.
Последний вызов, into_async, преобразует драйвер I2C в асинхронный режим. Это позволяет драйверу датчика использовать асинхронные операции I2C, а наша задача может ожидать завершения передач вместо активного ожидания (busy-waiting).
Такое разделение на блокирующий и асинхронный режимы характерно для многих драйверов esp-hal. Типы драйверов параметризуются режимом, а драйверы с поддержкой асинхронности после преобразования используют маркер режима Async. Конкретный способ настройки зависит от периферии, но принцип один и тот же: создать драйвер, настроить его, а затем перевести в асинхронный режим, если он будет использоваться из асинхронного кода.
Наконец, shtc3(i2c) оборачивает шину I2C в драйвер датчика из крейта shtcx2.
Чтение датчика
SHTC3 — датчик температуры и влажности, которым управляют по I2C. В документации SHTC3 описаны набор команд, режимы измерения, требования к времени и формулы преобразования. Реализовывать эти детали вручную нам не нужно: крейт shtcx2 отправляет команды и преобразует необработанные значения.
Приложению всё равно нужно соблюдать последовательность измерения датчика. Показания не возвращаются одной транзакцией I2C; вместо этого мы:
- Запускаем измерение у датчика.
- Ждём, пока измерение завершится.
- Читаем и преобразуем результат.
В коде это выглядит так:
// Read sensor
if let Err(e) = sht.start_measurement(PowerMode::NormalMode).await {
error!("Failed to start measurement: {:?}", e);
Timer::after(Duration::from_secs(1)).await;
continue;
}
// Wait for the maximum measurement duration reported by the sensor driver.
let duration = max_measurement_duration(&sht, PowerMode::NormalMode);
Timer::after(Duration::from_micros(duration.into())).await;
let measurement = match sht.get_measurement_result().await {
Ok(m) => m,
Err(e) => {
error!("Failed to get measurement result: {:?}", e);
Timer::after(Duration::from_secs(1)).await;
continue;
}
};
Мы используем PowerMode::NormalMode — режим с более высокой точностью измерений. После start_measurement(...) SHTC3 занят внутренним преобразованием температуры и влажности. Если попытаться прочитать результат сразу, датчик может ещё не подтвердить чтение по I2C.
Существует несколько способов, которыми датчик может сообщить о готовности измерения. Некоторые датчики имеют вывод прерывания data-ready. SHTC3 на этой плате — 4-контактное I2C-устройство (VDD, SCL, SDA и VSS), поэтому у него нет вывода прерывания, на который наш пример мог бы ждать. SHTC3 также поддерживает clock stretching, но команды измерения, которые использует этот драйвер, его не включают. Поэтому мы ждём максимальную длительность измерения, которую возвращает max_measurement_duration(...), прежде чем пытаться прочитать результат.
Это ожидание отделено от асинхронных передач по I2C. Асинхронный I2C позволяет задаче уступить управление, пока отправляются или принимаются команды I2C; Timer::after(...).await позволяет задаче уступить управление, пока датчик выполняет измерение. В обоих случаях исполнитель может выполнять другую работу, а не простаивать в активном ожидании.
Возвращаемое измерение предоставляет типизированные значения температуры и влажности:
info!(
" {:.2} °C | {:.2} %RH",
measurement.temperature.as_degrees_celsius(),
measurement.humidity.as_percent(),
);
Цикл повторяет эту последовательность раз в секунду и выводит в лог последнее показание.
Запуск приложения
Соберите и прошейте приложение из project/part1/:
cargo run --release
Эта команда делает два действия. Сначала Cargo собирает приложение в режиме release для настроенной целевой платформы ESP32-C3. Затем Cargo запускает раннер, специфичный для целевой платформы, из .cargo/config.toml:
[target.riscv32imc-unknown-none-elf]
runner = "espflash flash --monitor --chip esp32c3"
Раннер вызывает espflash, чтобы прошить бинарный файл на плату. Флаг --monitor оставляет монитор последовательного порта открытым после прошивки, поэтому логи устройства выводятся в том же терминале.
Как только приложение запустится, оно инициализирует плату, настроит асинхронный драйвер I2C и будет многократно считывать датчик SHTC3. На каждом цикле выводится последнее измерение температуры и относительной влажности.
Вы должны увидеть примерно такой вывод:
INFO - 24.31 °C | 42.18 %RH
INFO - 24.30 °C | 42.21 %RH
INFO - 24.32 °C | 42.20 %RH
На этом этапе приложение умеет инициализировать плату, настраивать асинхронный драйвер I2C и считывать данные датчика. В следующей главе мы подключим устройство к Wi-Fi, чтобы эти измерения можно было отправить с платы.
Подключение к Wi-Fi
В этой главе мы подключим плату к локальной сети Wi-Fi, получим IP-адрес и будем отправлять каждое показание датчика на компьютер в той же сети. Готовый код для этой главы находится в project/part2/.
Инициализация HAL, настройка I2C и последовательность измерения SHTC3 из главы Чтение данных с датчика остаются прежними. Меняется структура проекта, а также появляются новые сетевые компоненты: куча (heap), сетевой стек, фоновые задачи и учётные данные, известные на этапе компиляции.
От одного файла к модулям
В предыдущей главе весь код находился в main.rs. Когда мы добавим Wi-Fi и HTTP, этот файл станет трудно читать, поэтому мы перенесём связанный код в модули:
mod http;
mod network;
mod sensor;
sensor.rsсодержит вспомогательную функцию измерения SHTC3, которая раньше находилась в основном цикле. Шина I2C по-прежнему создаётся вmain, но запуск измерения, ожидание результата и его преобразование теперь находятся вread_sensor:
pub async fn read_sensor(sht: &mut ShtC3<I2c<'static, esp_hal::Async>>) -> Option<(f32, f32)> {
// Read sensor
if let Err(e) = sht.start_measurement(PowerMode::NormalMode).await {
error!("Failed to start measurement: {:?}", e);
return None;
}
// Wait for the maximum measurement duration reported by the sensor driver.
let duration = max_measurement_duration(sht, PowerMode::NormalMode);
Timer::after(Duration::from_micros(duration.into())).await;
match sht.get_measurement_result().await {
Ok(m) => {
let temp = m.temperature.as_degrees_celsius();
let humidity = m.humidity.as_percent();
info!(" {:.2} °C | {:.2} %RH", temp, humidity);
Some((temp, humidity))
}
Err(e) => {
error!("Failed to get measurement result: {:?}", e);
None
}
}
}
network.rsсодержит две фоновые задачи, которые поддерживают соединение по Wi-Fi и работу IP-стека.http.rsформирует JSON-нагрузку и отправляет её с помощью минимального HTTP-запросаPOST.main.rsпо-прежнему инициализирует аппаратное обеспечение, запускает сетевой стек и выполняет основной цикл приложения.
Такое разделение делает новые сетевые понятия проще для чтения, а те же модули будут расширяться в следующих главах.
Зачем нужен esp-alloc
Программа no_std не имеет глобального аллокатора, если мы его не предоставим. В предыдущей главе куча не требовалась: драйвер датчика работает с буферами на стеке, и мы не создавали String или Vec.
Wi-Fi устроен иначе. esp-radio — обёртка над предкомпилированным драйвером радиомодуля, и этот драйвер выделяет память во время выполнения. embassy-net и наш HTTP-помощник тоже требуют крейт alloc, например когда format! формирует тело JSON. Без кучи приложение даже не сможет запустить радиомодуль.
Поэтому мы добавляем esp-alloc и включаем крейт alloc:
extern crate alloc;
use esp_alloc as _;
extern crate alloc; делает Vec, String и format! доступными в no_std. use esp_alloc as _; подключает крейт, чтобы его глобальный аллокатор был включён в сборку, хотя по имени мы к нему не обращаемся.
Затем создаём кучу до запуска планировщика и радиомодуля:
esp_alloc::heap_allocator!(#[ram(reclaimed)] size: 64 * 1024);
esp_alloc::heap_allocator!(size: 36 * 1024);
heap_allocator! можно вызывать несколько раз. Каждый вызов добавляет регион в один и тот же глобальный аллокатор. Для ESP32-C3, который используется в этом курсе, эти вызовы резервируют два региона кучи:
- 64 КиБ памяти, освобождённой от загрузчика (reclaimed), которая выбирается с помощью
#[ram(reclaimed)]. - Ещё 36 КиБ памяти.
Подробнее см. раздел Allocating Memory книги The Rust on ESP Book (на английском языке).
Для
esp-radioнужны одновременно куча и запущенный планировщик. Поэтому мы по-прежнему вызываемesp_rtos::start(...)доesp_radio::wifi::new(...), как и требует документация крейта.
Инициализация сетевого стека
Сеть разделена на два уровня. esp-radio управляет аппаратным Wi-Fi. embassy-net — IP-стек, который работает поверх этого интерфейса и предоставляет DHCP, DNS и TCP.
Создание Wi-Fi-интерфейса
После запуска планировщика мы берём владение периферией Wi-Fi и создаём интерфейс в режиме станции:
let (controller, interfaces) = esp_radio::wifi::new(peripherals.WIFI, Default::default())
.expect("Failed to create WiFi controller");
let wifi_interface = interfaces.station;
esp_radio::wifi::new возвращает WifiController и набор интерфейсов. Контроллер подключается к точке доступа. interfaces.station — драйвер embassy-net, то есть Wi-Fi-клиент, который может отправлять и принимать кадры Ethernet.
Конфигурация embassy-net
embassy-net настраивается в двух местах: через feature-флаги Cargo и через значение Config, которое мы передаём во время выполнения.
Feature-флаги крейта определяют, какие протоколы будут скомпилированы:
embassy-net = { version = "0.9.1", features = [
"dhcpv4",
"dns",
"medium-ethernet",
"tcp",
"udp",
] }
medium-ethernetобязательна, поскольку Wi-Fi-станция представляет собой интерфейс, похожий на Ethernet.dhcpv4позволяет стеку запрашивать IPv4-адрес у точки доступа.udpиспользуется DHCP и DNS.tcpиdnsпонадобятся позже, когда мы откроем сокет и разрешим имя хоста.
Также включаем поддержку Embassy в esp-rtos и feature wifi в esp-radio:
esp-radio = { version = "0.18.0", features = [
"esp32c3",
"log-04",
"unstable",
"wifi",
] }
esp-rtos = { version = "0.3.0", features = [
"embassy",
"esp-radio",
"esp32c3",
"log-04",
] }
Во время выполнения мы запрашиваем аренду адреса DHCP и инициализируем генератор стека случайным значением из аппаратного генератора случайных чисел (RNG):
let config = embassy_net::Config::dhcpv4(Default::default());
let rng = Rng::new();
let seed = (rng.random() as u64) << 32 | rng.random() as u64;
Config::dhcpv4 — аналог в embassy-net для фразы «получить адрес от роутера». Статический адрес тоже сработал бы, но DHCP — обычная настройка для сети разработки, и она позволяет не прописывать IP-адрес в прошивке.
Случайный seed не является криптографическим ключом. embassy-net использует его для значений протокола, которые не должны быть предсказуемыми для разных устройств, например для начальных номеров последовательности TCP и идентификаторов транзакций DHCP.
Почему ресурсы стека статические
embassy_net::new требует драйвер Wi-Fi, конфигурацию, начальное случайное значение (seed) и буфер StackResources<N>. N — количество сокетов, которые стек может держать открытыми одновременно. Мы используем 3: этого хватает для DHCP, DNS и одного TCP-соединения.
// Init network stack
static STACK_RESOURCES_CELL: static_cell::StaticCell<StackResources<3>> =
static_cell::StaticCell::new();
let (stack, runner) = embassy_net::new(
wifi_interface,
config,
STACK_RESOURCES_CELL
.uninit()
.write(StackResources::<3>::new()),
seed,
);
Эти ресурсы, Runner и запускаемые задачи должны иметь время жизни 'static. Задачи Embassy не могут заимствовать данные из стека main: после запуска задача может работать до конца программы, поэтому все её аргументы должны жить дольше, чем main.
Обычный локальный let resources = StackResources::new(); был бы уничтожен при выходе из main (хотя этого никогда не происходит, система типов не может этим воспользоваться), и его нельзя было бы передать задаче 'static. static mut скомпилировался бы только с unsafe.
StaticCell решает эту проблему. Это ячейка одноразовой инициализации: мы инициализируем её во время выполнения и получаем &'static mut StackResources<3>. После этого embassy_net::new возвращает Stack, который можно использовать из кода приложения, и Runner, который нужно непрерывно опрашивать.
Зачем нужны задачи
Стек не будет полезен, пока в фоне не запущены два цикла:
spawner.spawn(connection(controller).expect("failed to spawn connection task"));
spawner.spawn(net_task(runner).expect("failed to spawn network task"));
Обе функции — задачи Embassy, созданные с помощью #[embassy_executor::task].
connection владеет WifiController. Она настраивает режим станции, подключается и, если связь разрывается, ждёт и пробует снова:
#[embassy_executor::task]
pub async fn connection(mut controller: WifiController<'static>) {
debug!("start connection task");
loop {
if controller.is_connected() {
let _ = controller.wait_for_disconnect_async().await;
Timer::after(Duration::from_millis(5000)).await;
continue;
}
let station_config = WifiConfig::Station(
StationConfig::default()
.with_ssid(SSID)
.with_password(PASSWORD.into()),
);
controller
.set_config(&station_config)
.expect("Failed to set WiFi configuration");
debug!("About to connect...");
match controller.connect_async().await {
Ok(_) => info!("Wifi connected!"),
Err(e) => {
error!("Failed to connect to wifi: {e:?}");
Timer::after(Duration::from_millis(5000)).await
}
}
}
}
net_task владеет Runner. Её единственная задача — непрерывно опрашивать IP-стек:
#[embassy_executor::task]
pub async fn net_task(mut runner: Runner<'static, Interface<'static>>) {
runner.run().await
}
Мы используем задачи, потому что эти циклы никогда не завершаются и должны продвигаться одновременно с приложением. Если бы main сам вызывал runner.run().await, он никогда не считывал бы датчик и не отправлял бы HTTP. Если бы он выполнял только цикл подключения, DHCP и TCP зависли бы. Исполнитель Embassy переключается между задачами, как только одна из них вызывает .await, поэтому радиомодуль, IP-стек и основной цикл приложения могут работать на одном ядре.
Времена жизни 'static у WifiController и Runner — то же ограничение, что и раньше: запущенная задача может пережить функцию, которая её создала.
Переменные окружения
Имя сети и пароль не записаны строковыми литералами. Они считываются во время компиляции:
const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");
env! раскрывается в &'static str, взятый из окружения во время работы rustc. Если SSID или PASSWORD не заданы, сборка завершится с ошибкой. Так учётные данные не попадают в дерево исходного кода, а передаются при сборке или прошивке:
SSID="your-ssid" PASSWORD="your-password" cargo run --release
Cargo передаёт переменные окружения процесса компилятору, поэтому эти значения встраиваются в прошивку. Чтобы сменить сеть позже, нужно пересобрать прошивку.
HTTP-помощник использует необязательные переменные для получателя на вашем компьютере:
const HOST_IP: Option<&'static str> = option_env!("HOST_IP");
const HTTP_PORT: Option<&'static str> = option_env!("HTTP_PORT");
const DEFAULT_HTTP_PORT: u16 = 8080;
option_env! возвращает None, если переменная не задана, поэтому прошивка всё равно соберётся. Во время работы send_sensor_data записывает ошибку в лог и завершается, если HOST_IP не задан. HTTP_PORT по умолчанию равен 8080.
Ожидание адреса и отправка данных
После запуска задач main ждёт, пока связь не установится и DHCP не назначит адрес:
stack.wait_link_up().await;
debug!("Waiting to get IP address...");
loop {
if let Some(config) = stack.config_v4() {
debug!("Got IP: {}", config.address);
break;
}
embassy_time::Timer::after(Duration::from_millis(500)).await;
}
wait_link_up() завершается, когда станция ассоциирована с точкой доступа. Следующий цикл ждёт, пока config_v4() не вернёт Some, — это значит, что аренда адреса DHCP получена. Только после этого мы начинаем отправку.
Цикл приложения теперь состоит из двух вызовов: считать датчик и, если чтение успешно, отправить значения:
// Read sensor
if let Some((temp, humidity)) = read_sensor(&mut sht).await {
// Send sensor data via HTTP
let _ = send_sensor_data(stack, temp, humidity).await;
}
send_sensor_data формирует небольшое тело JSON, открывает TCP-сокет и отправляет вручную сформированный HTTP/1.0-запрос POST на /sensor:
// Prepare HTTP payload (JSON)
let temperature_str = format!("{:.2}", temperature);
let humidity_str = format!("{:.2}", humidity);
let body = format!(
r#"{{"temperature":{},"humidity":{}}}"#,
temperature_str, humidity_str
);
Эта глава использует HTTP лишь как простой способ убедиться, что устройство может связаться с другим хостом. В следующей главе HTTP заменяется на MQTT.
Запуск приложения
Прошивка отправляет данные на компьютер в той же сети. Из корня репозитория сначала запустите приёмник:
cargo xtask http-server
Задача выводит IP-адрес хоста, если может его определить. Используйте это значение как HOST_IP. Затем из project/part2/ прошейте приложение:
SSID="your-ssid" PASSWORD="your-password" HOST_IP="192.168.1.10" cargo run --release
Замените SSID, пароль и IP-адрес на свои: на название вашей сети, пароль и адрес, который вывела xtask. Если сервер работает не на порту 8080, можно задать HTTP_PORT.
Как только устройство подключится, в мониторе последовательного порта появится сообщение о подключении:
INFO - Wifi connected!
INFO - 24.31 °C | 42.18 %RH
INFO - HTTP request sent
Терминал http-server должен выводить каждую отправленную полезную нагрузку:
sensor payload={"temperature":24.31,"humidity":42.18}
На этом этапе плата может подключиться к сети Wi-Fi, получить IP-адрес и отправлять измерения за пределы устройства. В следующей главе мы опубликуем эти показания в брокер MQTT.
Публикация данных в брокер MQTT
В этой главе мы заменяем временный HTTP-запрос из предыдущей главы на MQTT. Плата подключается к брокеру MQTT, считывает датчик SHTC3 и публикует температуру раз в секунду. Готовый код для этой главы находится в project/part3/.
Инициализация I2C, Wi-Fi и сетевого стека из главы Подключение к Wi-Fi остаётся прежней. Основные изменения — клиент MQTT, отдельная задача, которая владеет датчиком, и локальный брокер, позволяющий просматривать каждую публикацию.
Учётные данные Wi-Fi
В этой главе по-прежнему используются SSID и PASSWORD, заданные на этапе компиляции, как и в предыдущей главе. Оставив настройку Wi-Fi без изменений, мы можем сосредоточиться на MQTT. В следующей главе эти учётные данные будут заменены порталом Wi-Fi-провизионинга.
Что добавляет MQTT
MQTT — протокол «публикация/подписка» (publish/subscribe). Вместо того чтобы отправлять HTTP-запрос напрямую определённой конечной точке приложения, клиент публикует полезную нагрузку в именованный топик на брокере. Другие клиенты могут подписаться на этот топик и получать эту нагрузку.
В этой главе используются три понятия MQTT:
- Брокер принимает подключения и маршрутизирует сообщения.
- ESP32-C3 является издателем (publisher).
measurement/temperature— топик, в который устройство публикует данные.
Локальный брокер, который мы запустим позже в этой главе, также подписывается на measurement/#. Символ # соответствует всем топикам внутри measurement, поэтому логгер брокера выводит сообщения, отправленные в measurement/temperature.
Добавление клиента MQTT
Реализация MQTT находится в новом модуле:
mod mqtt;
Мы используем крейт rust-mqtt:
rust-mqtt = { version = "0.5.1", default-features = false, features = ["bump", "v5"] }
Feature-флаги по умолчанию отключены, потому что это приложение no_std. Feature v5 включает MQTT версии 5, которая соответствует локальному брокеру, а bump включает хранение в bump-буфере крейта. Bump-буфер заранее резервирует блок памяти фиксированного размера и позволяет клиенту использовать его для пакетов MQTT без отдельного выделения кучи для каждой операции.
Запуск задачи MQTT
В предыдущей главе main владел датчиком и выполнял каждое чтение и каждый HTTP-запрос. Теперь мы переносим этот цикл в задачу Embassy:
spawner.spawn(mqtt_task(stack, sht).expect("failed to spawn MQTT task"));
Stack — лёгкий копируемый дескриптор сетевого стека, поэтому его можно передать задаче, пока net_task продолжает опрашивать Runner стека. Драйвер SHTC3 передаётся в mqtt_task, что даёт этой задаче эксклюзивное владение датчиком I2C.
После запуска фоновых задач у main больше нет работы приложения. Он остаётся активным и уступает управление исполнителю:
// Keep main task alive
loop {
embassy_time::Timer::after(embassy_time::Duration::from_secs(60)).await;
}
Задача подключения сети, Runner сети и задача MQTT теперь могут продвигаться независимо друг от друга, когда другие задачи ожидают.
Инициализация MQTT
Задача MQTT сначала резервирует буферы приёма и передачи для своего TCP-сокета:
let mut rx_buffer = [0; 4096];
let mut tx_buffer = [0; 4096];
Эти буферы существуют на протяжении всей работы задачи и повторно используются при каждой попытке подключения.
Ожидание сети
MQTT работает поверх TCP, поэтому задача не может подключиться к брокеру, пока Wi-Fi не подключён и DHCP не настроил сетевой стек:
// Wait for network to be ready before attempting connection
debug!("Waiting for WiFi link to come up...");
stack.wait_link_up().await;
debug!("WiFi link up, waiting for network configuration...");
// Wait for DHCP to assign an IP address
stack.wait_config_up().await;
debug!("Waiting to get IP address...");
loop {
if let Some(config) = stack.config_v4() {
debug!("Got IP: {}", config.address);
break;
}
Timer::after(Duration::from_millis(500)).await;
}
// Check if we still have a valid network config before proceeding
if !stack.is_config_up() {
debug!("Network config lost, retrying...");
continue;
}
wait_link_up() ждёт, пока поднимется канал Wi-Fi. wait_config_up() ждёт сетевой конфигурации, а config_v4() подтверждает, что IPv4-адрес доступен. Если конфигурация пропадёт до начала подключения к брокеру, внешний цикл начнёт последовательность заново.
Поиск брокера
Адрес и порт брокера считываются во время компиляции:
let host = match HOST_IP {
Some(h) => h,
None => {
error!(
"No HOST_IP set. Provide e.g. HOST_IP=10.0.0.10 (or hostname) and optional BROKER_PORT."
);
Timer::after(Duration::from_secs(5)).await;
continue;
}
};
// Default to rumqttd's v5 listener port (1884) unless overridden
let port: u16 = BROKER_PORT
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(1884);
Оба значения встраиваются в прошивку при её сборке. HOST_IP обязателен для логики приложения, но использует option_env!, поэтому отсутствие значения приводит к ошибке в логе, а не к ошибке компиляции. BROKER_PORT необязателен и по умолчанию равен 1884 — порту, который использует локальный брокер MQTT этого курса.
Несмотря на название, HOST_IP может содержать и имя хоста. Задача сначала пытается разобрать значение как IPv4-адрес. Если разбор не удаётся, она запрашивает у сетевого стека разрешение IPv4-адреса через DNS:
// If host is an IPv4 literal, bypass DNS
let address = match host.parse::<Ipv4Address>() {
Ok(ipv4) => IpAddress::Ipv4(ipv4),
Err(_) => match stack.dns_query(host, DnsQueryType::A).await {
Ok(addresses) if !addresses.is_empty() => addresses[0],
Ok(_) => {
error!("DNS query returned no addresses for {}", host);
Timer::after(Duration::from_secs(5)).await;
continue;
}
Err(e) => {
error!("DNS lookup error: {e:?}");
Timer::after(Duration::from_secs(5)).await;
continue;
}
},
};
Использование литерального IP-адреса позволяет избежать DNS-запроса. Это удобно для локального брокера, адрес которого обычно совпадает с IP-адресом компьютера, на котором вы работаете.
Открытие TCP и подключение MQTT
Далее задача открывает TCP-сокет к найденному адресу брокера:
let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer);
socket.set_timeout(Some(embassy_time::Duration::from_secs(10)));
let remote_endpoint = (address, port);
info!("connecting to MQTT broker at {}:{}...", host, port);
let connection = socket.connect(remote_endpoint).await;
if let Err(e) = connection {
error!("connect error: {:?}", e);
Timer::after(Duration::from_secs(5)).await;
continue;
}
info!("connected!");
Десятисекундный таймаут не даёт попытке подключения ждать бесконечно. Если TCP-подключение не удалось, задача ждёт пять секунд и возвращается к началу внешнего цикла.
Когда TCP подключён, можно инициализировать клиент MQTT:
let mut mqtt_buffer_storage = [0; 1024];
let mut mqtt_buffer = BumpBuffer::new(&mut mqtt_buffer_storage);
let mut client = Client::<_, _, 1, 1, 1, 1>::new(&mut mqtt_buffer);
let connect_options = ConnectOptions::new().clean_start();
let client_id = MqttString::from_str("esp32c3").expect("valid MQTT client id");
if let Err(e) = client
.connect(socket, &connect_options, Some(client_id))
.await
{
error!("MQTT connect error: {:?}", e);
continue;
}
BumpBuffer размером 1 КиБ предоставляет рабочую память для пакетов MQTT. ConnectOptions::new().clean_start() просит брокер начать работу без предыдущей сессии MQTT, а esp32c3 идентифицирует это устройство для брокера. client.connect(...) отправляет пакет MQTT CONNECT через TCP-сокет и ждёт, пока брокер его примет.
TCP и MQTT — разные уровни соединения. Успешный вызов socket.connect(...) лишь устанавливает байтовый поток к брокеру. Успешный вызов client.connect(...) завершает рукопожатие MQTT поверх этого потока.
Наконец, задача создаёт топик и параметры публикации один раз для каждого соединения MQTT:
let topic = TopicName::new(
MqttString::from_str("measurement/temperature").expect("valid MQTT topic string"),
)
.expect("valid MQTT topic name");
let publish_options = PublicationOptions::new(TopicReference::Name(topic)).retain();
Топик — measurement/temperature. Вызов retain() просит брокер сохранить последнее значение, поэтому новый подписчик получит актуальную температуру, не дожидаясь следующего показания датчика.
Публикация измерений
Внутренний цикл — основная рабочая часть задачи. Перед чтением датчика он проверяет, что канал Wi-Fi и сетевая конфигурация всё ещё доступны:
// Check network state before attempting operations
if !stack.is_link_up() || !stack.is_config_up() {
debug!("Network connection lost, reconnecting...");
break;
}
Если одно из них потеряно, break выходит из цикла публикации. Внешний цикл затем ждёт сеть и создаёт новые соединения TCP и MQTT.
При каждой публикации задача считывает датчик и форматирует температуру:
// Read sensor
let (temp, _) = match read_sensor(&mut sht).await {
Some(reading) => reading,
None => {
Timer::after(Duration::from_secs(1)).await;
continue;
}
};
let mut temperature_string: heapless::String<32> = heapless::String::new();
write!(temperature_string, "{:.2}", temp).expect("write! failed!");
read_sensor по-прежнему возвращает и температуру, и влажность, но в этой главе публикуется только температура. Шаблон _ намеренно отбрасывает значение влажности. heapless::String фиксированной ёмкости хранит отформатированное число, что позволяет не выделять память в куче для каждого показания.
Байты строки становятся полезной нагрузкой MQTT:
if let Err(e) = client
.publish(&publish_options, Bytes::from(temperature_string.as_bytes()))
.await
{
error!("MQTT publish error: {:?}", e);
break;
}
Если публикация прошла успешно, задача ждёт одну секунду и снова считывает датчик. Если нет, задача выходит из внутреннего цикла и переподключается. Так у приложения есть единый путь восстановления после потери канала Wi-Fi, потери IP-конфигурации, закрытия TCP-сокета или ошибки MQTT.
Запуск приложения
Запустите брокер MQTT из корня репозитория:
cargo xtask mqtt-server
Команда запускает брокер MQTT версии 5 на порту 1884, подписывает свой логгер на measurement/# и выводит IP-адрес компьютера, если может его определить:
MQTT server listening on 0.0.0.0:1884
Host IP (best effort): 192.168.1.10
Remote devices should connect to 192.168.1.10:1884
Subscribed to `measurement/#` and printing all incoming MQTT messages.
Оставьте эту команду запущенной. В другом терминале перейдите в project/part3/ и прошейте приложение:
SSID="your-ssid" PASSWORD="your-password" HOST_IP="your-ip" cargo run --release
Замените your-ip на IP-адрес хоста, который вывела предыдущая команда, и укажите учётные данные своей сети. Плата и компьютер с брокером должны быть доступны друг для друга по сети. Чтобы использовать другой порт брокера, передайте один и тот же порт обеим командам:
cargo xtask mqtt-server --port 1885
SSID="your-ssid" PASSWORD="your-password" HOST_IP="your-ip" BROKER_PORT="1885" cargo run --release
После того как Wi-Fi, TCP и MQTT подключатся, в мониторе последовательного порта должны появиться показания датчика:
INFO - Wifi connected!
INFO - connecting to MQTT broker at 192.168.1.10:1884...
INFO - connected!
INFO - 24.31 °C | 42.18 %RH
Терминал брокера должен вывести каждую сохранённую публикацию:
Topic: measurement/temperature | Payload: 24.31
На этом этапе плата может поддерживать соединение MQTT и публиковать измерения датчика в брокер. В следующей главе мы заменим учётные данные Wi-Fi, заданные на этапе компиляции, порталом провизионинга.
Wi-Fi-провизионинг
В этой главе мы удаляем учётные данные Wi-Fi, известные на этапе компиляции. Вместо этого плата запускает собственную точку доступа и отдаёт небольшую веб-страницу, на которой вы вводите название сети и пароль. Как только учётные данные поступают, плата выходит из режима точки доступа, подключается к сети и, как и раньше, публикует данные датчика в брокер MQTT. Готовый код для этой главы находится в project/part4/.
Настройка I2C, куча и задача MQTT из главы Публикация данных в брокер MQTT в основном остаются прежними. Задача MQTT теперь также публикует влажность в measurement/humidity. Основные изменения — второй сетевой стек, набор небольших серверов, работающих на устройстве, и канал, который передаёт учётные данные от веб-страницы к задаче подключения Wi-Fi.
Что такое Wi-Fi-провизионинг?
До сих пор SSID и PASSWORD считывались с помощью env! и встраивались в прошивку. Это работает на рабочем столе разработчика, но не масштабируется на реальные устройства:
- Для каждой сети понадобится отдельная прошивка.
- Человек, который устанавливает устройство, обычно не может пересобрать и прошить прошивку.
- Смена роутера или пароля требует перепрошивки устройства.
Wi-Fi-провизионинг — это процесс передачи учётных данных сети ненастроенному устройству во время работы. Прошивка одинакова для всех устройств, а учётные данные пользователь вводит при первоначальной настройке.
Существует несколько способов передать учётные данные на устройство, но два самых распространённых:
- Точка доступа (SoftAP): устройство создаёт собственную сеть Wi-Fi. Пользователь подключается к ней с телефона или ноутбука и вводит учётные данные либо на веб-странице, которую отдаёт устройство, либо в приложении-компаньоне.
- Bluetooth Low Energy (BLE): устройство рассылает объявления по BLE, а приложение на телефоне передаёт учётные данные по соединению BLE. Радиомодулю Wi-Fi устройства никогда не нужно работать как точке доступа, а телефон во время настройки остаётся подключённым к собственной сети.
ESP-IDF поддерживает оба транспорта в своей унифицированной платформе провизионинга, а Espressif предоставляет приложения-компаньоны для Android и iOS.
В этой главе мы используем подход с точкой доступа и веб-страницей. Для него пользователю нужен только браузер, а из стека Wi-Fi используется то, что у нас уже есть.
Процесс провизионинга выглядит так:
sequenceDiagram
participant User as Телефон или ноутбук
participant AP as ESP32-C3 (точка доступа)
participant Router as Роутер Wi-Fi
participant Broker as Брокер MQTT
AP->>AP: Запуск точки доступа "esp-radio"
User->>AP: Подключение к "esp-radio"
AP-->>User: Аренда DHCP (192.168.2.x)
User->>AP: GET http://192.168.2.1/
AP-->>User: Страница провизионинга
User->>AP: POST /save (SSID и пароль)
AP-->>User: "Configuration Saved!"
AP->>AP: Переход в режим станции
AP->>Router: Подключение к сети с полученными учётными данными
Router-->>AP: Аренда DHCP
AP->>Broker: Подключение и публикация измерений
Режимы станции и точки доступа
Радиомодуль Wi-Fi может выполнять две роли:
- В режиме станции (STA) устройство — клиент, который подключается к существующей сети. Именно так мы работали в предыдущих главах.
- В режиме точки доступа (AP) устройство и есть сеть. Другие устройства находят его SSID, подключаются к нему и взаимодействуют с ним напрямую.
Провизионингу нужны оба режима: режим AP — чтобы получить учётные данные, и режим STA — чтобы использовать их.
Два интерфейса, два сетевых стека
esp_radio::wifi::new уже возвращает не только интерфейс станции. В этой главе мы используем оба интерфейса — точки доступа и станции — из Interfaces:
let (controller, interfaces) = esp_radio::wifi::new(peripherals.WIFI, Default::default())
.expect("Failed to create WiFi controller");
// Start with AP device for provisioning
let ap_device = interfaces.access_point;
// Store STA device for later use
let sta_device = interfaces.station;
Каждый интерфейс — отдельное сетевое устройство, поэтому у каждого из них свой стек embassy-net:
let NetworkStacks {
ap_stack,
ap_runner,
sta_stack,
sta_runner,
} = create_network_stacks(ap_device, sta_device, gw_ip_addr);
Два стека настроены по-разному:
let ap_config = embassy_net::Config::ipv4_static(StaticConfigV4 {
address: Ipv4Cidr::new(gw_ip_addr, 24),
gateway: Some(gw_ip_addr),
dns_servers: Default::default(),
});
let sta_config = embassy_net::Config::dhcpv4(Default::default());
- Стек станции использует DHCP, как и раньше, потому что адрес назначает роутер.
- Стек точки доступа использует статический адрес. В собственной сети устройства нет роутера, поэтому никто не может выдать ему адрес. Устройство является шлюзом (gateway) подсети
/24и использует адрес шлюза для себя.
Адрес шлюза по умолчанию — 192.168.2.1, его можно переопределить на этапе компиляции через GATEWAY_IP:
let gw_ip_addr_str = GW_IP_ADDR_ENV.unwrap_or("192.168.2.1");
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip");
Стеку точки доступа также нужно больше сокетов, чем стеку станции, поскольку он размещает серверы DHCP, DNS и HTTP:
// Init network stack for AP (provisioning)
// Increased from 3 to 6 to accommodate: DHCP UDP socket, Captive Portal UDP socket,
// HTTP TCP socket, and some buffer for concurrent connections
static AP_STACK_RESOURCES_CELL: static_cell::StaticCell<StackResources<6>> =
static_cell::StaticCell::new();
let (ap_stack, ap_runner) = embassy_net::new(
ap_device,
ap_config,
AP_STACK_RESOURCES_CELL
.uninit()
.write(StackResources::<6>::new()),
seed,
);
Стек станции сохраняет StackResources<3> из предыдущих глав. Оба стека по-прежнему нуждаются в StaticCell по тем же причинам, связанным с временем жизни 'static, которые описаны в главе Подключение к Wi-Fi.
Настройка контроллера
Интерфейсы передают только кадры. WifiController определяет, какую роль играет радиомодуль, а задача connection запускает его в режиме точки доступа:
// Start in AP mode first for provisioning. `set_config` starts/restarts the
// Wi-Fi controller as needed in esp-radio 0.18.
let ap_config = WifiConfig::AccessPoint(AccessPointConfig::default().with_ssid("esp-radio"));
controller
.set_config(&ap_config)
.expect("Failed to set WiFi configuration");
info!("WiFi AP started!");
AccessPointConfig по умолчанию задаёт открытую сеть на канале 1. Мы меняем только SSID на esp-radio — это сеть, к которой вы подключитесь с телефона или компьютера.
После получения учётных данных та же задача заменяет конфигурацию на StationConfig:
// Configure station mode. This replaces the AP configuration and restarts Wi-Fi.
debug!("Configuring station mode...");
let station_config = StationConfig::default()
.with_ssid(credentials.ssid.as_str())
.with_password(credentials.password.as_str().into());
let sta_config = WifiConfig::Station(station_config);
controller
.set_config(&sta_config)
.expect("Failed to set station mode WiFi configuration");
debug!("WiFi station configured!");
set_config перезапускает Wi-Fi в новом режиме, поэтому точка доступа исчезает, и страница провизионинга становится недоступна. Затем станция подключается и переподключается тем же циклом, что и в предыдущих главах.
esp-radioтакже поддерживает одновременную работу обеих ролей черезConfig::AccessPointStation. Это позволило бы порталу оставаться активным, пока устройство пробует новые учётные данные, например чтобы сообщить пользователю о неверном пароле. Мы разделяем два этапа, чтобы пример оставался небольшим.
Задачи и зачем они нужны
Теперь у приложения семь параллельных активностей. main инициализирует аппаратное обеспечение, создаёт общие ресурсы и запускает большинство задач:
spawner.spawn(
connection(controller, wifi_credentials_channel).expect("failed to spawn connection task"),
);
spawner.spawn(net_task(ap_runner).expect("failed to spawn AP network task"));
spawner.spawn(sta_net_task(sta_runner).expect("failed to spawn STA network task"));
spawner.spawn(run_dhcp(ap_stack, gw_ip_addr).expect("failed to spawn DHCP task"));
spawner.spawn(
run_captive_portal(ap_stack, gw_ip_addr).expect("failed to spawn captive portal task"),
);
spawner.spawn(mqtt_task(sta_stack, sht).expect("failed to spawn MQTT task"));
| Задача | Стек | Назначение |
|---|---|---|
connection | — | Владеет WifiController, запускает точку доступа, затем переключается в режим станции. |
net_task | AP | Опрашивает Runner стека точки доступа. |
sta_net_task | STA | Опрашивает Runner стека станции. |
run_dhcp | AP | Выдаёт IP-адреса клиентам, которые подключаются к точке доступа. |
run_captive_portal | AP | Отвечает на DNS-запросы адресом самого устройства. |
run_http_server | AP | Отдаёт страницу провизионинга и принимает учётные данные. |
mqtt_task | STA | Считывает датчик и публикует измерения, как в предыдущей главе. |
Каждая из этих задач — цикл, который никогда не завершается. Большую часть времени они ждут пакета, соединения или сообщения. Поскольку это отдельные задачи Embassy, исполнитель запускает ту, у которой есть работа, а ожидающие задачи ничего не стоят. Один последовательный main не смог бы одновременно ждать HTTP-запрос, отвечать на DHCP и опрашивать два сетевых стека.
Две задачи-раннера
Каждый стек embassy-net требует, чтобы его Runner непрерывно опрашивался, поэтому нам нужна одна задача-раннер на каждый стек:
#[embassy_executor::task]
pub async fn net_task(mut runner: Runner<'static, Interface<'static>>) {
runner.run().await
}
#[embassy_executor::task]
pub async fn sta_net_task(mut runner: Runner<'static, Interface<'static>>) {
runner.run().await
}
Две функции идентичны. Они существуют отдельно, потому что у функции, помеченной #[embassy_executor::task], есть пул задач фиксированного размера, выделенный статически, и его размер по умолчанию равен единице. Повторный запуск net_task привёл бы к ошибке. Альтернатива — #[embassy_executor::task(pool_size = 2)], но две именованные задачи яснее показывают, какой раннер относится к какому стеку.
Серверы на точке доступа
Телефон, который подключается к esp-radio, ожидает те же службы, что предоставляет обычный роутер. Задачи провизионинга предоставляют минимальный набор с помощью крейтов из семейства edge-net, которые работают поверх embassy-net через edge-nal-embassy.
run_dhcp использует edge-dhcp, чтобы назначать адреса клиентам. Без него пользователю пришлось бы вручную настраивать статический IP-адрес, прежде чем открыть страницу:
loop {
_ = io::server::run(
&mut Server::<_, 64>::new_with_et(gw_ip_addr),
&ServerOptions::new(gw_ip_addr, Some(&mut gw_buf)),
&mut bound_socket,
&mut buf,
)
.await
.inspect_err(|e| log::warn!("DHCP server error: {e:?}"));
Timer::after(EmbassyDuration::from_millis(500)).await;
}
run_captive_portal использует edge-captive — DNS-сервер, который отвечает на каждый запрос адресом шлюза. Его цель — чтобы любое имя хоста вело на страницу провизионинга:
loop {
debug!("Starting Captive Portal DNS server");
_ = run(
&udp_stack,
SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DNS_PORT)),
&mut tx_buf,
&mut rx_buf,
gw_ip_addr,
Duration::from_secs(60),
)
.await
.inspect_err(|e| log::warn!("Captive Portal DNS server error: {e:?}"));
Timer::after(EmbassyDuration::from_millis(500)).await;
}
run_http_server использует edge-http, чтобы отдавать страницу провизионинга на порту 80:
static TCP_BUFFERS: static_cell::StaticCell<TcpBuffers<1, 2048, 2048>> =
static_cell::StaticCell::new();
let buffers = TCP_BUFFERS.uninit().write(TcpBuffers::new());
let tcp = Tcp::new(stack, buffers);
let mut acceptor = tcp
.bind(SocketAddr::new(
core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
HTTP_PORT,
))
.await
.expect("Failed to bind TCP socket");
let handler = HttpHandler {
wifi_credentials_channel,
};
let mut server = HttpServer::<1, 2048, 32>::new();
loop {
if let Err(_e) = server
.run(Some(50000), &mut acceptor, &handler)
.await
.inspect_err(|e| error!("HTTP server error: {:?}", e))
{
Timer::after(EmbassyDuration::from_millis(100)).await;
}
}
main запускает HTTP-сервер только после того, как стек точки доступа поднят и настроен, а затем выводит в лог инструкции по подключению к порталу:
ap_stack.wait_link_up().await;
info!("WiFi Provisioning Portal Ready");
info!("1. Connect to the AP: `esp-radio`");
info!("2. Navigate to: http://{gw_ip_addr_str}/");
ap_stack.wait_config_up().await;
ap_stack
.config_v4()
.inspect(|c| debug!("ipv4 config: {c:?}"));
spawner.spawn(
run_http_server(ap_stack, wifi_credentials_channel)
.expect("failed to spawn HTTP server task"),
);
Задача mqtt_task запускается сразу, но пока ничего полезного не делает. Её первый шаг — stack.wait_link_up() на стеке станции, поэтому она ждёт, пока провизионинг не завершится и станция не подключится к сети.
Captive-порталы
Когда телефон или ноутбук подключается к новой сети, операционная система запрашивает известный URL, например /generate_204 на Android или /connecttest.txt в Windows, чтобы проверить доступ в Интернет. Публичные точки доступа используют эту проверку, чтобы автоматически открыть страницу входа. Такой механизм называется captive-портал.
Обработчик HTTP перенаправляет такие пробные URL на страницу провизионинга:
// Handle captive portal redirects
const CAPTIVE_PATHS: &[&str] =
&["/generate_204", "/gen_204", "/ncsi.txt", "/connecttest.txt"];
if CAPTIVE_PATHS.contains(&path) {
conn.initiate_response(302, Some("Found"), &[("Location", "/")])
.await?;
return Ok(());
}
Чтобы операционная система отправила свой запрос на устройство, клиент должен использовать устройство в качестве DNS-сервера. В этом проекте DNS-ответчик слушает UDP-порт 8853, а клиенты отправляют DNS-запросы на стандартный порт 53, и DHCP-сервер не сообщает адрес DNS-сервера. В результате страница провизионинга не открывается автоматически: вы открываете её сами по адресу http://192.168.2.1/.
Передача учётных данных между задачами
HTTP-сервер получает учётные данные, но WifiController принадлежит задаче connection. Эти две задачи обмениваются данными через Channel из embassy-sync:
// Create WiFi credentials channel
static WIFI_CREDENTIALS_CHANNEL_CELL: static_cell::StaticCell<
Channel<embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, WifiCredentials, 1>,
> = static_cell::StaticCell::new();
let wifi_credentials_channel = WIFI_CREDENTIALS_CHANNEL_CELL.uninit().write(Channel::new());
Канал имеет ёмкость в одно сообщение, поскольку нам нужен только один набор учётных данных. Он использует CriticalSectionRawMutex, который безопасно разделять между задачами и прерываниями. Как и ресурсы стека, он хранится в StaticCell, чтобы обе задачи могли держать ссылку &'static.
Тип сообщения — небольшая структура:
#[derive(Clone, Debug, Deserialize)]
pub struct WifiCredentials {
pub ssid: String<32>,
pub password: String<64>,
}
Поля — строки heapless::String фиксированной ёмкости, размер которых соответствует ограничениям Wi-Fi: SSID — не более 32 байт, а парольная фраза WPA2 — не более 63 символов или 64 шестнадцатеричных цифр. Реализация Deserialize через derive позволяет обработчику HTTP создать структуру напрямую из тела JSON-запроса. Это возможно благодаря feature serde в heapless.
Задача connection ожидает на приёмном конце:
// Wait for credentials
debug!("Waiting for WiFi credentials...");
let credentials = wifi_credentials_channel.receiver().receive().await;
info!("Credentials received! SSID: {}", credentials.ssid);
// Give the HTTP handler time to send the saved page before switching off AP mode.
debug!("Delaying AP shutdown to allow HTTP response to complete...");
Timer::after(EmbassyDuration::from_secs(2)).await;
receive().await приостанавливает задачу до прихода сообщения, не опрашивая канал. Двухсекундная задержка даёт HTTP-серверу время отправить страницу подтверждения до того, как точка доступа выключится.
Отдача страницы провизионинга
Обработчик HTTP сопоставляет метод запроса и путь. GET / возвращает страницу провизионинга:
(Method::Get, "/") => {
conn.initiate_response(
200,
Some("OK"),
&[("Content-Type", "text/html; charset=utf-8")],
)
.await?;
conn.write_all(HOME_HTML.as_bytes()).await?;
}
GET /saved возвращает страницу подтверждения аналогичным образом. Любой другой путь возвращает 404 Not Found.
Форма отправляет учётные данные в формате JSON на POST /save:
(Method::Post, "/save") => {
// Read request body
let mut buf = [0u8; 256];
let n = match conn.read(&mut buf).await {
Ok(0) => {
conn.initiate_response(400, Some("Bad Request"), &[])
.await?;
return Ok(());
}
Ok(n) => n,
Err(e) => return Err(e),
};
match serde_json_core::from_slice::<WifiCredentials>(&buf[..n])
.ok()
.map(|(credentials, _)| credentials)
{
Some(credentials) => {
debug!(
"WiFi Credentials Received: SSID: {} | Password: {}",
credentials.ssid, credentials.password
);
self.wifi_credentials_channel
.sender()
.send(credentials)
.await;
debug!("Credentials sent!");
conn.initiate_response(
200,
Some("OK"),
&[("Content-Type", "text/html; charset=utf-8")],
)
.await?;
conn.write_all(SAVED_HTML.as_bytes()).await?;
}
None => {
conn.initiate_response(400, Some("Bad Request"), &[])
.await?;
}
}
}
Обработчик считывает тело запроса в буфер на 256 байт и разбирает его с помощью serde-json-core — парсера JSON для no_std, который ничего не выделяет в куче. Если разбор успешен, учётные данные отправляются через канал, а обработчик отвечает страницей подтверждения. Если тело пустое или не является корректным JSON, обработчик отвечает 400 Bad Request.
Обработчик записывает полученный пароль в лог на уровне
debug. При изучении это полезно, но реальный продукт никогда не должен записывать секреты в лог.
HTML-шаблоны
Две страницы находятся в project/part4/assets/templates/:
home.html— форма провизионинга с полями SSID и пароля.saved.html— страница подтверждения, которая показывается после отправки учётных данных.
На устройстве нет файловой системы. Вместо этого include_str! встраивает оба файла в прошивку во время компиляции:
// HTML templates embedded at compile time
const HOME_HTML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/templates/home.html"
));
const SAVED_HTML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/templates/saved.html"
));
CARGO_MANIFEST_DIR — это каталог, содержащий Cargo.toml, поэтому пути не зависят от каталога, из которого вы запускаете Cargo. Страницы становятся константами &'static str, которые хранятся во флеш-памяти, а обработчик записывает их байты напрямую в TCP-соединение.
При редактировании страниц учитывайте несколько моментов:
- Несмотря на название каталога, страницы не обрабатываются шаблонизатором. Они отдаются точно в том виде, в котором написаны.
- Изменения требуют пересборки и повторной прошивки, потому что HTML входит в образ прошивки.
home.htmlвыводитesp-radioи192.168.2.1как обычный текст. Если вы измените SSID точки доступа илиGATEWAY_IP, обновите страницу тоже.- Форма использует небольшой вызов
fetchна JavaScript, чтобы отправить JSON, а не стандартную кодировку формы браузера. Именно это позволяет прошивке разобрать тело с помощьюserde-json-coreвWifiCredentials. При успехе скрипт переходит на/saved. - Каждый байт HTML и CSS увеличивает размер прошивки. Встроенные стили и отсутствие внешних ресурсов делают страницы самодостаточными, что важно, потому что во время подключения к точке доступа у клиента нет доступа в Интернет.
Запуск приложения
Запустите брокер MQTT из корня репозитория:
cargo xtask mqtt-server
В другом терминале перейдите в project/part4/ и прошейте приложение. SSID и PASSWORD больше не нужны:
HOST_IP="your-ip" cargo run --release
Замените your-ip на IP-адрес хоста, который вывела команда cargo xtask mqtt-server. BROKER_PORT работает так же, как в предыдущей главе, а GATEWAY_IP меняет адрес портала провизионинга.
После загрузки в мониторе последовательного порта отображаются инструкции по порталу:
INFO - WiFi AP started!
INFO - Starting Captive Portal DNS server on port 8853
INFO - WiFi Provisioning Portal Ready
INFO - 1. Connect to the AP: `esp-radio`
INFO - 2. Navigate to: http://192.168.2.1/
INFO - Starting HTTP server on port 80
Затем выполните провизионинг устройства:
- На телефоне или компьютере подключитесь к открытой сети Wi-Fi
esp-radio. - Откройте
http://192.168.2.1/в браузере. Используйтеhttp://, а неhttps://. - Введите SSID и пароль сети, в которой доступен брокер MQTT, и нажмите Save Configuration.
- Переподключите телефон или компьютер к обычной сети. Сеть
esp-radioисчезнет, как только устройство переключится в режим станции.
Монитор последовательного порта показывает переход в режим станции, затем подключение к MQTT и показания датчика:
INFO - Credentials received! SSID: your-ssid
INFO - Connecting to WiFi network...
INFO - Successfully connected to WiFi!
INFO - connecting to MQTT broker at 192.168.1.10:1884...
INFO - connected!
INFO - 24.31 °C | 42.18 %RH
Терминал брокера теперь выводит оба топика:
Topic: measurement/temperature | Payload: 24.31
Topic: measurement/humidity | Payload: 42.18
Если пароль неверный, задача connection продолжает повторять попытки каждые пять секунд. Сбросьте плату, чтобы снова запустить портал провизионинга.
Ограничения
Этот пример показывает основу Wi-Fi-провизионинга, но не включает несколько вещей, которые понадобились бы продукту:
- Учётные данные не сохраняются. Они существуют только в RAM, поэтому после каждого сброса портал провизионинга запускается заново. Реальное устройство сохраняло бы их во флеш-памяти и запускало портал, только если сохранённых действительных учётных данных нет.
- Точка доступа открыта. Любой поблизости может подключиться к ней и отправить учётные данные, а пароль передаётся по обычному HTTP. Решить эту проблему можно, защитив точку доступа паролем WPA2 или применив протокол провизионинга с собственным шифрованием, например схемы безопасности из компонента provisioning в ESP-IDF.
- Нет обратной связи при ошибке. Точка доступа уже пропадает к моменту, когда устройство пробует учётные данные, поэтому пользователь не видит на странице сообщение об ошибке подключения.
На этом этапе устройству больше не нужны учётные данные на этапе компиляции: одна и та же прошивка может подключиться к любой сети. В следующей главе мы рассмотрим обновление прошивки в полевых условиях с помощью обновлений по воздуху (OTA).