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, получим 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.