Подключение к 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.