Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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_taskAPОпрашивает Runner стека точки доступа.
sta_net_taskSTAОпрашивает Runner стека станции.
run_dhcpAPВыдаёт IP-адреса клиентам, которые подключаются к точке доступа.
run_captive_portalAPОтвечает на DNS-запросы адресом самого устройства.
run_http_serverAPОтдаёт страницу провизионинга и принимает учётные данные.
mqtt_taskSTAСчитывает датчик и публикует измерения, как в предыдущей главе.

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

Затем выполните провизионинг устройства:

  1. На телефоне или компьютере подключитесь к открытой сети Wi-Fi esp-radio.
  2. Откройте http://192.168.2.1/ в браузере. Используйте http://, а не https://.
  3. Введите SSID и пароль сети, в которой доступен брокер MQTT, и нажмите Save Configuration.
  4. Переподключите телефон или компьютер к обычной сети. Сеть 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).