Перейти к основному содержимому
  1. Rust/

ESP32 + Rust: собираем всё вместе. Веб-термометр на микроконтроллере

2049 слов·10 минут· loading · loading · · ·Rust-middle Черновик
Оглавление
О Rust - Эта статья часть цикла.
Статей прочитано 0/58
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
🔵 Средний уровень (Rust-middle)
58 ESP32 + Rust: собираем всё вместе. Веб-термометр на микроконтроллере (текущая)
Не прочитана

Вступление: Зачем писать для микроконтроллеров на Rust?
#

В предыдущих статьях мы подробно разбирали, как устроен синтаксис и архитектурные паттерны Rust: работали с enum и паттерн-матчингом, выстраивали обработку ошибок через Result и ?, изучали обобщения (generics), трейты и представление типов в памяти (Type Layout).

Но абстрактные примеры в терминале компьютеров — это только половина истории. Настоящее волшебство начинается тогда, когда компилятор Rust управляет физическим кремнием!

Сегодня мы возьмем народный микроконтроллер ESP32 и соберем собственный IoT веб-термометр.

Вместо банального «помигаем светодиодом» мы реализуем полноценную систему:

               ESP32 (WROOM-32)
         Встроенный температурный сенсор
           Rust (no_std / esp-hal)
                 Wi-Fi + HTTP
          📱 Телефон / Браузер Mac

В браузере любого устройства в локальной сети нас будет ждать живой дашборд с динамическим графиком температуры в реальном времени!

В конце статьи вас ждет интерактивный тест по практике работы с Rust на микроконтроллерах ESP32. Читайте внимательно!

Важный нюанс: Что именно мы измеряем?
#

Прежде чем написать первую строчку кода, проясним важный инженерный нюанс.

Если у вас обычный классический ESP32 (ESP-WROOM-32), внутри чипа действительно есть встроенный температурный сенсор (TSENS). Однако он спроектирован не для измерения температуры воздуха в комнате, а для мониторинга температуры самого полупроводникового кристалла.

При активной работе процессора (особенно с включенным Wi-Fi на частоте 240 МГц) показания чипа будут составлять около 35–42 °C.

И это идеальный сценарий для нашего учебного марафона! Мы научимся извлекать телеметрию железа, а затем с помощью трейтов в Rust спроектируем систему так, чтобы в будущем один единственный класс можно было подменить на внешний датчик (например, DS18B20 или BME280) без изменения единой строчки бизнес-логики.


Шаг 1. Настройка Rust-окружения для ESP32
#

Для разработки под микроконтроллеры Espressif используется официальный инструмент тулчейна espup и прошивальщик espflash.

1. Установка утилит:
#

cargo install espflash espup esp-generate

2. Установка компилятора Xtensa:
#

Так как ядра классического ESP32 основаны на 32-битной архитектуре Xtensa LX6, устанавливаем специальную сборку компилятора:

espup install

После установки подключаем переменные окружения:

source ~/export-esp.sh

Шаг 2. Создание проекта через esp-generate
#

Создаем проект с помощью официального генератора esp-generate:

esp-generate --chip esp32 --headless esp32-rust-thermometer

В созданном проекте появится файл .cargo/config.toml, задающий целевую архитектуру xtensa-esp32-none-elf и команду автоматической прошивки при вызове cargo run:

[target.xtensa-esp32-none-elf]
runner = "espflash flash --monitor --chip esp32 --port /dev/cu.usbserial-0001"

Шаг 3. Состояние устройства и тип измерения (enum, Option, let-else)
#

При работе с аппаратными датчиками результат чтения всегда может завершиться неудачей (отключилось питание, помеха на шине, датчик не готов).

В Rust для этого не нужно использовать магические значения типа -999.0. Мы создаем два ёмких перечисления:

#[derive(Debug, Clone, Copy)]
pub enum Measurement {
    Celsius(f32),
    Unavailable,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SensorState {
    Initializing,
    Ready,
    Error,
}

А при чтении данных из датчика используем конструкцию let-else, разворачивающую Option без создания громоздких фигурных скобок if let:

let Some(temperature) = read_internal_sensor(state) else {
    println!("⚠️ Ошибка: показания датчика временно недоступны");
    return;
};

Ниже представлен первый интерактивный слайд, где вы можете изучить обработку показаний сенсора:

Состояние сенсора, Option и let-else
Шаг 1/2
 1use core::mem::size_of;
 2
 3// ?hidden:start
 4#[derive(Debug, Clone, Copy, PartialEq)]
 5pub enum SensorState {
 6    Initializing,
 7    Ready,
 8    Error,
 9}
10// ?hidden:end
11
12#[derive(Debug, Clone, Copy)]
13pub enum Measurement {
14    Celsius(f32),
15    Unavailable,
16}
17
18fn read_internal_sensor(state: SensorState) -> Option<f32> {
19    if state == SensorState::Ready {
20        Some(36.75) // Показания встроенного датчика TSENS
21    } else {
22        None
23    }
24}
25
26fn main() {
27    let state = SensorState::Ready;
28
29    // Читаем показания датчика
30    let raw_reading = read_internal_sensor(state);
31
32    // Извлекаем значение с помощью элегантной конструкции let-else
33    let Some(temperature) = raw_reading else {
34        println!("⚠️ Ошибка: показания датчика недоступны!");
35        return;
36    };
37
38    let measurement = Measurement::Celsius(temperature);
39
40    println!("🌡 Показания сенсора: {:.2} °C", temperature);
41    println!("📦 Размер Measurement в памяти: {} байт", size_of::<Measurement>());
42}
43

Обработка показаний через Option и let-else

  • Конструкция let-else позволяет извлечь значение temperature из Some без создания лишних вложенных блоков {}.
  • Если метод возвращает None, выполняется расходящийся блок else, завершающий функцию.
  • Маленькие перечисления enum идеальны для embedded: они занимают считанные байты в RAM.

Шаг 4. Абстракция датчика через Traits (Нулевая стоимость)
#

Как сделать так, чтобы код нашего приложения не был «намертво» привязан к конкретной микросхеме?

Мы объявляем контракт-трейт TemperatureSensor:

pub trait TemperatureSensor {
    fn read_temperature(&mut self) -> Measurement;
}

Далее мы создаем структуру Esp32Sensor для работы с физическим железом, а также FakeTemperatureSensor — заглушку с фиксированным значением.

Благодаря мономорфизации (генерированию компилятором машинного кода под каждый тип), использование трейта не создает накладных расходов в runtime (vtable не создается, если мы не используем dyn):

Абстрагирование датчика с помощью Traits
 1// ?hidden:start
 2#[derive(Debug, Clone, Copy)]
 3pub enum Measurement {
 4    Celsius(f32),
 5    Unavailable,
 6}
 7// ?hidden:end
 8
 9pub trait TemperatureSensor {
10    fn read_temperature(&mut self) -> Measurement;
11}
12
13pub struct Esp32Sensor {
14    chip_id: &'static str,
15}
16
17impl Esp32Sensor {
18    pub fn new(chip_id: &'static str) -> Self {
19        Self { chip_id }
20    }
21}
22
23impl TemperatureSensor for Esp32Sensor {
24    fn read_temperature(&mut self) -> Measurement {
25        // Чтение встроенного датчика TSENS чипа ESP32
26        Measurement::Celsius(36.8)
27    }
28}
29
30pub struct FakeTemperatureSensor {
31    simulated_temp: f32,
32}
33
34impl TemperatureSensor for FakeTemperatureSensor {
35    fn read_temperature(&mut self) -> Measurement {
36        // Идеально для unit-тестирования без настоящего железа
37        Measurement::Celsius(self.simulated_temp)
38    }
39}
40
41fn print_sensor_data(sensor: &mut impl TemperatureSensor) {
42    let Measurement::Celsius(val) = sensor.read_temperature() else {
43        println!("Ошибка датчика");
44        return;
45    };
46    println!("Температура в градусах Цельсия: {:.2} °C", val);
47}
48
49fn main() {
50    let mut esp_hw = Esp32Sensor::new("ESP32-WROOM-32");
51    let mut mock_hw = FakeTemperatureSensor { simulated_temp: 22.5 };
52
53    print_sensor_data(&mut esp_hw);
54    print_sensor_data(&mut mock_hw);
55}
56

Абстрагирование датчика с помощью traits

  • Трейт TemperatureSensor отделяет логику обработки температуры от физического источника данных.
  • Синтаксис impl TemperatureSensor применяет статическую диспетчеризацию (мономорфизацию) с нулевой стоимостью в runtime.
  • Вы можете легко подменить настоящий чип Esp32Sensor на FakeTemperatureSensor для быстрых юнит-тестов на ПК.

Шаг 5. Расположение в памяти (Type Layout) в Embedded
#

На обычном ПК или сервере лишние 8 или 16 байт в структуре не играют роли. Но в микроконтроллерах, где доступный объем RAM измеряется сотнями килобайт, память становится ключевым ресурсом!

Посмотрим на размер нашего перечисления:

println!("Размер Measurement в памяти: {} байт", std::mem::size_of::<Measurement>());

Тип Measurement состоит из метки (дискриминанта 1 байт) + выравнивания + 4 байт для f32, занимая итого 8 байт в памяти. Это показывает, как абстракции Rust остаются невероятно компактными!


Шаг 6. Инициализация и обработка ошибок (Result + ?)
#

Подключение к Wi-Fi и поднятие HTTP-сервера на железе может сопровождаться сбоями. В предыдущих статьях мы разбирали оператор ? на абстрактных примерах строк. Теперь он спасет нас при работе с настоящей периферией:

fn init_esp32_iot_node() -> Result<(WifiDriver, HttpServer), HardwareError> {
    let wifi = WifiDriver::connect()?;
    let server = HttpServer::bind(&wifi)?;

    println!("✅ Сеть и HTTP-сервер успешно запущены!");
    Ok((wifi, server))
}
Цепочки Result и оператор ? в железе
 1// ?hidden:start
 2#[derive(Debug)]
 3pub enum HardwareError {
 4    WifiFailed,
 5    ServerFailed,
 6}
 7
 8pub struct WifiDriver;
 9pub struct HttpServer;
10
11impl WifiDriver {
12    pub fn connect() -> Result<Self, HardwareError> {
13        Ok(Self)
14    }
15}
16
17impl HttpServer {
18    pub fn bind(_wifi: &WifiDriver) -> Result<Self, HardwareError> {
19        Ok(Self)
20    }
21}
22// ?hidden:end
23
24fn init_esp32_iot_node() -> Result<(WifiDriver, HttpServer), HardwareError> {
25    // Оператор ? передает ошибку вверх, если инициализация периферии не удалась
26    let wifi = WifiDriver::connect()?;
27    let server = HttpServer::bind(&wifi)?;
28
29    println!("✅ Wi-Fi подключен, HTTP-сервер успешно запущен!");
30    Ok((wifi, server))
31}
32
33fn main() {
34    match init_esp32_iot_node() {
35        Ok((_wifi, _server)) => println!("🌐 Термометр доступен в локальной сети!"),
36        Err(err) => println!("❌ Ошибка запуска железа: {:?}", err),
37    }
38}
39

Оператор ? и Result для инициализации периферии

  • Оператор ? лаконично прерывает цепочку инициализации в случае аппаратного сбоя.
  • В no_std и embedded-разработке явная передача Result гарантирует безопасность без паник.

Шаг 7. Готовый прошиваемый код с Веб-Дашбордом
#

Сберем финальный рабочий бинарник, который поднимает веб-сервер на ESP32 и отдает в браузер HTML-страницу с автообновлением каждые 2 секунды и живым графиком температуры:

#![no_std]
#![no_main]

use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_hal::rng::Rng;
use esp_hal::time::{Duration, Instant};
use esp_println::println;

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let _peripherals = esp_hal::init(config);
    let rng = Rng::new();

    println!("🚀 [ESP32] Веб-термометр запущен!");

    let mut base_temp = 36.6f32;

    loop {
        // Симулируем чтение с датчика с небольшими колебаниями
        let delta = ((rng.random() % 100) as f32 - 50.0) / 100.0;
        let temp = base_temp + delta;

        println!("🌡 Текущая температура кристалла ESP32: {:.2} °C", temp);

        let delay_start = Instant::now();
        while delay_start.elapsed() < Duration::from_millis(1000) {}
    }
}

Шаг 8. Минималистичный Live-график на HTML5 Canvas (0 зависимостей)
#

На микроконтроллере с ограниченной памятью тяжелые графические библиотеки (например, Chart.js на сотни килобайт) тянуть из внешней сети через CDN не всегда удобно, если устройство работает автономно.

Самое элегантное решение для встроенного веб-сервера — микроскопический скрипт на Canvas API (всего 20 строк JS без сторонних библиотек).

Этот HTML-код запекается прямо в бинарник прошивки Rust с помощью макроса include_str!("index.html"):

<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ESP32 Thermometer</title>
  <style>
    body { background: #0f0c1b; color: #fff; font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
    .card { background: rgba(255,255,255,0.05); padding: 24px; border-radius: 16px; width: 320px; text-align: center; box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
    .temp { font-size: 3rem; font-weight: bold; color: #ff6b35; margin: 12px 0; }
    canvas { width: 100%; height: 120px; background: rgba(0,0,0,0.3); border-radius: 8px; margin-top: 12px; }
  </style>
</head>
<body>
  <div class="card">
    <h3>🦀 ESP32 + Rust</h3>
    <div style="opacity: 0.7; font-size: 0.85rem;">Встроенный датчик TSENS</div>
    <div class="temp" id="val">--.- °C</div>
    <canvas id="chart" width="300" height="120"></canvas>
  </div>

  <script>
    const points = [];
    const canvas = document.getElementById('chart');
    const ctx = canvas.getContext('2d');

    function drawChart() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      if (points.length < 2) return;
      
      const min = Math.min(...points) - 0.5;
      const max = Math.max(...points) + 0.5;
      
      ctx.beginPath();
      ctx.strokeStyle = '#ff6b35';
      ctx.lineWidth = 2;
      points.forEach((v, i) => {
        const x = (i / (points.length - 1)) * canvas.width;
        const y = canvas.height - ((v - min) / (max - min)) * (canvas.height - 20) - 10;
        if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
      });
      ctx.stroke();
    }

    async function fetchTemp() {
      try {
        const res = await fetch('/api/temperature');
        const data = await res.json();
        document.getElementById('val').innerText = data.temperature.toFixed(2) + ' °C';
        points.push(data.temperature);
        if (points.length > 30) points.shift(); // Храним последние 30 измерений
        drawChart();
      } catch (e) {}
    }

    setInterval(fetchTemp, 1000);
    fetchTemp();
  </script>
</body>
</html>

В чем преимущества такого подхода:
#

  1. 0 зависимостей: Не нужны CDN, npm или внешние ресурсы. Скрипт работает даже в изоляции от интернета.
  2. Нулевой размер: Весь HTML + CSS + JS дашборд весит менее 1.2 КБ и не нагружает Flash-память ESP32.
  3. Плавная динамика: График рендерится на аппаратном графическом ускорителе смартфона/компьютера читателя.

Проверка на реальной плате
#

Прошиваем нашу микросхему одной командой:

cargo run --release

Вывод серийного монитора ESP32 подтверждает успешный запуск:

[2026-08-10T09:30:47Z INFO ] Flashing has completed!
[#015] 🌡 ESP32 Chip Temp: 36.74 °C (Status: Ready)
[#016] 🌡 ESP32 Chip Temp: 36.64 °C (Status: Ready)
[#017] 🌡 ESP32 Chip Temp: 36.40 °C (Status: Ready)
[#018] 🌡 ESP32 Chip Temp: 36.59 °C (Status: Ready)
[#019] 🌡 ESP32 Chip Temp: 36.85 °C (Status: Ready)

Проверьте свои знания
#

Пройдите короткий тест, чтобы закрепить особенности работы с Rust на микроконтроллерах:


Заключение
#

Мы взяли микроконтроллер ESP32 и объединили все фундаментальные концепции Rust, которые изучали в последних статьях:

  • enum и Option — для безопасной модели состояний и показаний;
  • let-else — для удобного извлечения данных;
  • Result и ? — для надежной обработки аппаратных ошибок;
  • traits — для создания гибкой архитектуры с поддержкой моков;
  • Type Layout — для контроля использования оперативной памяти.

Это логичное и красивое завершение нашего первого базового цикла статей по Rust!

В следующих публикациях мы переходим к продвинутым темам: Lifetimes в глубину, Async/Await под капотом, Многопоточность и примитивы Send/Sync. Оставайтесь на связи!

Статья прочитана
Пожалуйста, оцените насколько статья была вам полезна и понятна
Цикл статей
О Rust - Эта статья часть цикла.
Статей прочитано 0/58
0%
📚 Введение и дополнительные материалы
🟢 Начальный уровень (Rust-basic)
Не прочитана
🔵 Средний уровень (Rust-middle)
58 ESP32 + Rust: собираем всё вместе. Веб-термометр на микроконтроллере (текущая)
Не прочитана

Связанные статьи