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).