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

Введение

В этой книге мы используем плату ESP32 DevKit v1 и язык Rust, чтобы собирать простые и интересные проекты. ESP32 — популярный микроконтроллер для IoT-приложений, и мы идём практическим путём: учимся, делая. Вы научитесь включать светодиод, когда в комнате темнеет, с помощью LDR; использовать ультразвуковой датчик, чтобы определить, что что-то находится рядом; управлять светодиодом по Wi-Fi; выводить изображения и текст на OLED-дисплей; проигрывать мелодии и звуковые сигналы на пищалке; управлять сервоприводом и многое другое.

Мы будем использовать среду no_std языка Rust. Программировать ESP32 можно и в среде std, но я считаю, что лучше начинать с no_std: так вы сможете применять ту же логику и при работе с другими микроконтроллерами.

Предварительные требования

Если вы ещё не читали “The Rust on ESP Book”, настоятельно рекомендую начать с неё. Эта книга тоже затронет настройку среды разработки и базовые понятия, но не будет углубляться в них, чтобы не повторять то, что уже подробно объяснено в официальной книге.

Также рекомендую прочитать “The Embedded Rust book” — вводную книгу об использовании Rust во встраиваемых системах на «голом железе» (Bare Metal).

Знакомство с железом

Мы будем использовать одну из отладочных плат — «ESP32 DevKit V1», которая имеет встроенные модули Wi-Fi и Bluetooth, а также интегрированный радиочастотный модуль

Техническая документация

Подробные технические сведения, спецификации и рекомендации можно найти в официальных даташитах:

Макетная плата

Плата ESP32 DevKit немного шире обычной. Если вы используете стандартную макетную плату, на ней может не хватить места, как было у меня. Чтобы решить эту проблему, я купил две мини-макетки, поставил ESP32 между ними и соединил каждую сторону.

На изображении в нижней части показано, как я подключил ESP32 к двум макетным платам. Верхняя часть просто показывает макетные платы до того, как к ним что-либо подключено.

Лицензия

Книга «impl Rust для ESP32» (этот проект) распространяется по следующим лицензиям:

  • Примеры кода и самостоятельные Cargo-проекты, содержащиеся в книге, распространяются на условиях как MIT License, так и Apache License v2.0.
  • Текст книги распространяется на условиях лицензии Creative Commons CC-BY-SA v4.0.

Поддержите проект

Вы можете поддержать книгу, поставив звезду проекту на GitHub или поделившись книгой с другими 😊

Отказ от ответственности:

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

Семейство ESP32

Если вы новичок и ищете, какой ESP32 купить, вас может сбить с толку обилие вариантов. В этом разделе мы разберёмся с ними.

Семейство ESP32 — это серия недорогих маломощных систем на кристалле (System on Chip, SoC) от компании Espressif. ESP32 стал преемником ESP8266 и с тех пор разросся во множество вариантов.

Варианты SoC

Семейство ESP32 включает следующие часто используемые варианты SoC:

  • ESP32
  • ESP32 серии S (ESP32-S2, ESP32-S3)
  • ESP32 серии C (ESP32-C3, ESP32-C6, ESP32-C5)
  • ESP32 серии H (ESP32-H2)
  • ESP32-P серии (ESP32-P4)

Если вы выбираете модель для конкретного проекта или разрабатываете продукт, используйте таблицы сравнения выше, чтобы найти наиболее подходящий вариант. Полный список моделей и их характеристик есть на странице сравнения продуктов на сайте Espressif.

Для простого обзора самых распространённых моделей ESP32 и их основных возможностей посмотрите эту таблицу на сайте Done.land.

System on Chip (SoC) vs модуль vs отладочная плата (Devkit)

ESP32 бывает в трёх формах: System on Chip (SoC), модуль и отладочная плата (Devkit). Каждая из них служит своей цели и подходит для разных этапов разработки или интеграции.

System on Chip (SoC)

SoC — это ядро и самая базовая форма ESP32. System on Chip объединяет основные компоненты электронной системы или компьютера в одной интегральной схеме (IC). SoC ESP32, как правило, включает такие компоненты:

  • CPU (процессор)
  • ROM и SRAM
  • Периферия
  • Беспроводная связь (зависит от модели)

Применение:
SoC в первую очередь предназначены для встраивания в собственные аппаратные разработки. Они подходят производителям, которые хотят добавить функциональность ESP32 в свои продукты. Однако при использовании SoC напрямую в собственной разработке вы сами отвечаете за RF-дизайн и соответствие нормативным требованиям готового продукта.


Модуль

Модули строятся на базе SoC и предлагают более удобное готовое решение. Распространённые примеры модулей ESP32 — популярные серии WROOM и WROVER. Например, ESP32-WROOM-32 широко используется и узнаётся по металлическому корпусу квадратной формы на многих отладочных платах.

Почему стоит выбрать модуль?

  • Соответствие нормативам: Многие модули ESP32 уже протестированы и сертифицированы для определённых конфигураций. Использование сертифицированного модуля может упростить соответствие нормативам, но это не делает весь продукт автоматически сертифицированным.
  • Встроенные компоненты: Модули включают многие компоненты, необходимые для работы ESP32, например флеш-память, кварцевый генератор и RF-компоненты. В зависимости от модуля в нём может быть и печатная антенна.
  • Экранирование: Многие модули имеют металлический RF-экран над радиочастотными и другими компонентами.

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


Отладочная плата (Devkit)

Отладочные платы упрощают работу с модулями ESP32 при прототипировании и разработке. Они включают дополнительные компоненты, которые облегчают работу с ESP32 как новичкам, так и опытным разработчикам.

Ключевые особенности отладочных плат:

  • Интерфейс USB: Удобный способ питать плату и загружать в неё программы.
  • Стабилизаторы напряжения: Обеспечивают подходящее напряжение питания для ESP32.
  • Выводы: Делают выводы ESP32 доступными для подключения внешних компонентов, таких как датчики и дисплеи.
  • Кнопки Boot и Reset: Позволяют управлять работой модуля.

Применение:
Отладочные платы идеально подходят для быстрого прототипирования и экспериментов. Популярные примеры — ESP32 DevKit v1 и ESP32-S3-DevkitC, которыми широко пользуются разработчики и энтузиасты.


Почему ESP32 DevKit v1?

Признаюсь: когда я впервые захотел попробовать ESP32, я не знал о множестве вариантов. Я искал на сайте интернет-магазина, и большинство результатов показывали ESP32-WROOM-32 от разных производителей. Я просто взял один из них. Позже я узнал, что есть и другие варианты. Однако на момент написания книги большинство из них либо были нелегко доступны там, где я живу, либо стоили дороже этого. Этот вариант остаётся одним из самых популярных.

Поэтому в этой книге мы не будем усложнять и выберем популярную и доступную плату «ESP32 DevKit V1», которая отлично подходит для разработки и обучения.

Как найти?

Если вы будете искать на сайте интернет-магазина, скорее всего, найдёте её под названием вроде «ESP32 Development Board (ESP-WROOM-32)». Ищите плату с модулем ESP32-WROOM-32 и 30 выводами, как показано ниже.

Сравнить характеристики и выводы платы можно по этой картинке.

Характеристики

ESP32 — это двухъядерный 32-битный процессор с поддержкой Wi-Fi и Bluetooth, отлично подходящий для беспроводных IoT-приложений.

Ниже перечислены базовые характеристики ESP32-WROOM-32, которые используются на этой отладочной плате:

  • Процессор: Xtensa 32-bit LX6
  • Количество ядер: 2
  • Тактовая частота: 240 МГц
  • Флеш-память: 4 МБ
  • ROM: 448 КБ (постоянная память с программами, необходимыми для работы ESP32)
  • SRAM: 520 КБ (для хранения данных и инструкций)
  • АЦП: два 12-битных SAR ADC, 18 каналов
  • UART: 3
  • SPI: 4
  • I2C: 2
  • Wi-Fi: IEEE 802.11 b/g/n/e/i (802.11n до 150 Мбит/с)
  • Bluetooth: v4.2 BR/EDR и Bluetooth Low Energy (BLE)
  • Рабочее напряжение: 2,3–3,6 В
  • Deep Sleep: 100 мкА

Ссылки

Распиновка ESP32

Схема выводов ESP32 DevKit V1

Лицензия: CC-BY-SA 4.0

Приведённая схема выводов основана на оригинальной схеме, созданной сайтом CircuitState. Там же есть подробные описания каждого вывода. Посмотреть их можно здесь.

Отказ от ответственности: эта книга не связана с CircuitState и не аффилирована с ним. Я включил эту схему, потому что нашёл её полезной во время своих исследований.

Ключевые моменты

  • GPIO только для входа: выводы GPIO 34, 35, 36 и 39 работают только на вход и не могут использоваться как выходы. На схеме эти выводы обозначены префиксом «GPIX» и отмечены «X», что означает, что выход недоступен. По возможности используйте выводы, выделенные на схеме фиолетовым цветом.

  • Прошивка и отладка: GPIO 1 (Tx) и GPIO 3 (Rx) предназначены для прошивки и отладки.

  • Выводы АЦП: выводы с обозначением ADC1_[номер] относятся к ADC1, а выводы с обозначением ADC2_[номер] — к ADC2.

Среда разработки

Официальная документация содержит более полные инструкции по настройке. Однако я кратко расскажу об основных инструментах и настройках, которые понадобятся для наших упражнений. Если возникнут проблемы, обратитесь к официальной документации.

cargo-binstall

Чтобы установить бинарные утилиты Rust без сборки из исходников через cargo install или ручной загрузки пакетов, можно использовать cargo-binstall. Мы применим его, чтобы установить инструмент espflash.

#![allow(unused)]
fn main() {
cargo install cargo-binstall
}

espflash

«espflash — это утилита для прошивки через последовательный порт, основанная на esptool.py, для SoC и модулей Espressif.» Именно её мы будем использовать (если не используем probe-rs), чтобы загрузить наш код на устройство и запустить его.

cargo binstall espflash

Если возникнут проблемы, можно установить именно ту версию, которая использовалась при написании этой книги.

cargo binstall espflash@4.2.0

После установки введите команду espflash, чтобы проверить, что всё работает.

espflash --version

Шаблоны ESP-RS

Мы будем использовать шаблоны, которые предоставляет ESP-RS. Их два:

  • esp-generate: шаблон no_std. Большую часть времени мы будем работать именно с ним.
  • esp-idf-template: шаблон std.

esp-generate

Утилита esp-generate предназначена для создания приложений no_std. Сейчас она поддерживает ESP32, ESP32-C2/C3/C6, ESP32-H2 и ESP32-S2/S3.

cargo install esp-generate --locked

Если хотите повторить код ровно так, как он есть в этом проекте, установите ту версию esp-generate, которая использовалась для создания примеров:

cargo install esp-generate@1.0.0 --locked

Создание проекта с esp-generate

В этой книге мы будем использовать ESP32. Я настоятельно рекомендую брать то же оборудование, чтобы было проще следовать за материалом.

# Замените PROJECT_NAME и --chip на ваш чип и название проекта.
esp-generate --chip esp32 PROJECT_NAME

Тулчейны для целей RISC-V и Xtensa

Также понадобится espup, чтобы установить необходимые тулчейны. Подробности можно найти здесь.

cargo binstall espup

или точная версия, использованная на момент написания книги:

cargo binstall espup@0.16.0

Затем установите тулчейн:

espup install

Если хотите использовать проект ровно в том виде, в каком я его создал, может понадобиться точная версия тулчейна Rust. Для этого выполните команду:

espup install --toolchain-version 1.90.0

Примечание: Устанавливайте эту конкретную версию только если собираетесь клонировать и запускать примеры проекта ровно в том виде, в каком они есть. Несовпадение версий может привести к странным ошибкам (пример ошибки: asm! macro is not allowed in naked functions).

Использование примера проекта без изменений

Когда вы создаёте проект с помощью esp-generate, он автоматически устанавливает «esp» как канал тулчейна. Если же вы хотите клонировать и использовать готовые проекты вместо создания с нуля, нужно указать имя тулчейна «book-1.0.0» (в файле rust-toolchain.toml проекта настроен тулчейн book-1.0.0). Это относится только к случаю, когда вы клонируете проект из репозитория esp32-projects и хотите запустить его без изменений.

espup install --name book-1.0.0 --toolchain-version 1.90.0

Окружение ESP Toolchain в оболочке

Если вы используете оболочку Fish, добавьте следующую строку в конфигурацию Fish:

echo '. ~/export-esp.sh' >> ~/.config/fish/config.fish

Если вы используете bash, добавьте следующую строку в файл ~/.bashrc:

echo '. ~/export-esp.sh' >> ~/.bashrc

Проверьте путь к компилятору и убедитесь, что тулчейн теперь доступен:

which xtensa-esp32-elf-gcc

Доступ к USB

Чтобы прошивать ESP32, вашему компьютеру нужно разрешение на доступ к USB-порту. Выполните эту команду, чтобы его выдать:

sudo usermod -a -G dialout $USER

Выйдите из системы и войдите снова, чтобы изменения вступили в силу.

Быстрый старт — Hello Embedded World!

Прежде чем погружаться в теорию и разбираться, как всё устроено, давайте сразу перейдём к делу. Используйте этот простой код, чтобы включить встроенный светодиод ESP32 DevKit.

Создание проекта

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 esp32-quick

Откроется экран с вопросом о выборе опций. Сейчас ничего выбирать не нужно. Просто сохраните настройки, нажав на клавиатуре «s».

Также можно выбрать опцию интеграции с редактором и затем указать конкретный редактор, которым вы пользуетесь.

Затем перейдите в папку проекта:

cd esp32-quick

Полный код

Откройте файл src/bin/main.rs. Внутри есть простая программа «Hello, World». Мы заменим её другим вариантом «Hello, World» для встраиваемого мира: заставим светодиод на плате мигать. Просто скопируйте код ниже и вставьте его в файл main.rs.

Пока не беспокойтесь о коде: всё объясним в следующей главе. Сейчас нам важно увидеть что-то интересное!

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::main;
use esp_hal::time::{Duration, Instant};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
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 mut led = Output::new(peripherals.GPIO2, Level::High, OutputConfig::default());

    loop {
        led.toggle();

        blocking_delay(Duration::from_millis(500));
    }

}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Клонирование готового проекта

Если столкнётесь с проблемами, можете клонировать (или посмотреть) проект, который я создал.

git clone https://github.com/ImplFerris/esp32-quick
cd esp32-quick/

Прошивка — «Беги, Rust, беги!»

Осталось загрузить код на устройство и посмотреть, как всё работает! Встроенный светодиод должен начать мигать.

Выполните следующую команду из папки проекта:

#![allow(unused)]
fn main() {
cargo run
}

Чтобы запустить в release-режиме:

#![allow(unused)]
fn main() {
cargo run --release
}

Слои абстракции

Работая со встраиваемым Rust, вы часто будете сталкиваться с терминами PAC, HAL и BSP. Это разные слои, которые помогают взаимодействовать с железом. Каждый слой даёт свой баланс между гибкостью и простотой использования.

Начнём с самого верхнего уровня абстракции и спустимся до самого нижнего.

слои абстракции

Board Support Package (BSP)

BSP, который в Rust также называют Board Support Crate, создаётся под конкретные отладочные платы. Он объединяет HAL с настройками конкретной платы и предоставляет готовые интерфейсы для встроенных компонентов, таких как светодиоды, кнопки и датчики. Это позволяет разработчикам сосредоточиться на логике приложения, а не на низкоуровневых деталях железа. Поскольку популярного BSP именно для ESP32 DevKit v1 нет, в этой книге мы не будем использовать этот подход.


Hardware Abstraction Layer (HAL)

HAL находится сразу под уровнем BSP. Если вы работаете с такими платами, как Raspberry Pi Pico или платы на основе ESP32, то в основном будете использовать уровень HAL.

HAL строится поверх PAC и предоставляет более простые интерфейсы высокого уровня для периферии микроконтроллера. Вместо работы с регистрами напрямую HAL предлагает методы и трейты, которые упрощают такие задачи, как настройка таймеров, организация последовательной связи или управление выводами GPIO.

HAL для микроконтроллеров обычно реализуют трейты embedded-hal — стандартные платформонезависимые интерфейсы для таких периферийных устройств, как GPIO, SPI, I2C и UART. Благодаря этому проще писать драйверы и библиотеки, которые работают на разном железе, если они используют совместимый HAL.

В этой книге мы будем использовать крейт esp-hal. Тот же HAL можно применять и с другими вариантами ESP32. Чтобы перейти на другое семейство ESP32, достаточно поменять feature-флаг в файле Cargo.toml.


Note

Слои ниже HAL редко используются напрямую. В большинстве случаев PAC доступен через HAL, а не сам по себе. Если только вы не работаете с чипом, для которого нет HAL, обычно нет необходимости обращаться к нижним слоям напрямую. В этой книге мы сосредоточимся на уровне HAL.

Peripheral Access Crate (PAC)

PAC — это самый низкий уровень абстракции. Это автоматически сгенерированные крейты, которые дают типобезопасный доступ к периферии микроконтроллера. Обычно их генерируют из файла SVD (System View Description) производителя с помощью таких инструментов, как svd2rust. PAC дают структурированный и безопасный способ напрямую работать с регистрами железа.

Крейт микроархитектуры

Он находится рядом с PAC в иерархии абстракций. Эти крейты привязаны к архитектуре процессорного ядра (например, ARM Cortex или Xtensa), используемого в вашем микроконтроллере. Они дают низкоуровневый доступ к базовым возможностям ядра и общей внутренней периферии.

Для ESP32 это крейты xtensa-lx и xtensa-lx-rt. Они отвечают за такие операции, как включение и отключение прерываний и доступ к внутренним таймерам. Для микроконтроллеров на ARM Cortex, например из семейств STM32, nRF или RP2040, аналогичные крейты — cortex-m и cortex-m-rt. Крейты микроархитектуры служат основой, на которой строятся HAL и BSP более высокого уровня, обеспечивая совместимость и переиспользование кода для чипов с одним и тем же ядром.

Raw MMIO

Raw MMIO (memory-mapped IO, ввод-вывод, отображённый в память) означает прямую работу с регистрами железа через чтение и запись по конкретным адресам памяти. Такой подход повторяет классическое управление регистрами в стиле C и требует использования блоков unsafe в Rust из-за потенциальных рисков. Мы не будем его рассматривать: я не встречал, чтобы кто-то использовал этот подход.

FAQ

1. Можно ли использовать другие отладочные платы ESP32?

Да, код можно использовать на большинстве плат с чипом «ESP32». Однако распиновка может отличаться, поэтому, возможно, понадобится внести некоторые изменения. В остальном всё должно работать!

2. Можно ли использовать другие чипы семейства ESP32, например ESP32-C3?

Да, но код может не заработать без изменений. Варианты ESP32 отличаются конфигурацией: в одних может не быть Bluetooth или Wi-Fi, у других другие характеристики. Часть кода может работать на разных вариантах ESP32, но различия в архитектуре, периферии и распределении GPIO могут потребовать доработок.

Концепции из книги вы всё равно можете изучить и попробовать применить к своему чипу. Для адаптации обращайтесь к официальным примерам ESP-HAL:
Примеры ESP-HAL

Тем не менее я настоятельно рекомендую взять плату ESP32 DevKit V1 для более гладкой работы. Она недорогая и хорошо работает с примерами из этой книги. А позже можно поэкспериментировать с другими вариантами ESP32!

3. Я клонировал проект, но он выдаёт ошибки. Что делать?

Если вы клонировали примеры проектов из книги и столкнулись с ошибками, скорее всего, дело в несовпадении версии тулчейна Rust. Поскольку эти проекты используют nightly-версию Rust, со временем могут появиться несовместимые изменения.

Что можно сделать?

У вас есть два варианта:

  1. Создать новый проект с помощью esp-generate, как описано в упражнениях.

    • Затем можно посмотреть код из книги и при необходимости подправить его.
  2. Откатить тулчейн Rust до версии, которая используется в книге.

    • Версию nightly, которую используют проекты этой книги, можно посмотреть здесь.
    • Установите подходящую версию, чтобы всё совпало.

4. При следовании инструкциям возникает ошибка импорта. Что делать?

Редактор кода обычно помогает с импортами, но иногда он может работать не так, как ожидается. Всегда можно сверить свой код с разделом «Полный код» или клонировать проект, сравнить и добавить недостающие импорты.

5. Где найти документацию по esp-hal?

Официальную документацию по esp-hal можно найти по ссылке:

Документация ESP-HAL

6. Я получаю такую ошибку:

error: linker `xtensa-esp32-elf-gcc` not found
  = note: No such file or directory (os error 2)

Это значит, что кросс-компилятор для ESP32 не установлен или не добавлен в PATH вашей системы.

Если вы используете оболочку Fish, добавьте следующую строку в конфигурацию Fish:

echo '. ~/export-esp.sh' >> ~/.config/fish/config.fish

Если вы используете bash, добавьте следующую строку в файл ~/.bashrc:

echo '. ~/export-esp.sh' >> ~/.bashrc

Проверьте путь к компилятору и убедитесь, что тулчейн теперь доступен:

which xtensa-esp32-elf-gcc

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

cargo build --release

Помощь и устранение неполадок

Если вы столкнётесь с ошибками, багами или другими проблемами при выполнении упражнений, вот несколько способов разобраться с ними.

1. Сравните с рабочим кодом

Проверьте полные примеры кода или клонируйте эталонный проект для сравнения. Внимательно просмотрите свой код и версии зависимостей в Cargo.toml. Ищите синтаксические и логические ошибки. Если нужный feature-флаг не включён или feature-флаги не совпадают, обязательно включите нужные, как показано в упражнении.

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

2. Поищите или создайте issue на GitHub

Посмотрите страницу issues, чтобы проверить, не встречалась ли кому-то та же проблема: https://github.com/r007/EmbeddedRust-Rus/issues?q=is%3Aissue

Если нет, создайте новое issue и подробно опишите свою проблему.

3. Спросите у сообщества

Сообщество embedded Rust очень активно в Matrix-чатах. Matrix — это открытая децентрализованная сеть для безопасного обмена сообщениями.

Вот несколько полезных каналов Matrix по темам, которые затрагивает эта книга:

  • Embedded Devices Working Group
    #rust-embedded:matrix.org
    Общие обсуждения использования Rust для разработки встраиваемых систем.

  • ESP32 Development
    #esp-rs:matrix.org
    Разработка на Rust для семейства чипов ESP32.

  • Debugging with Probe-rs
    #probe-rs:matrix.org
    Поддержка и обсуждение инструментов отладки probe-rs.

  • Embedded Graphics
    #rust-embedded-graphics:matrix.org
    Работа с embedded-graphics, библиотекой для рисования во встраиваемых системах.

Вы можете создать аккаунт Matrix и присоединиться к этим каналам, чтобы получить помощь от опытных разработчиков.

Больше чатов сообщества можно найти в разделе Awesome Embedded Rust - Community Chat Rooms.

4. Discord

Есть неофициальное сообщество embedded Rust в Discord, где можно задавать вопросы, обсуждать темы, делиться опытом и показывать свои проекты. Оно особенно полезно новичкам и для общих обсуждений.

Имейте в виду, что большинство мейнтейнеров HAL и участников embedded-экосистемы активнее в Matrix. Тем не менее этот Discord-сервер может стать хорошим местом, чтобы учиться и общаться с другими.

Присоединиться можно здесь: https://discord.gg/NHenanPUuG

От std к no_std

Мы успешно прошили и запустили первую программу, которая мигает светодиодом. Однако код и структуру проекта мы пока подробно не разбирали. В этом разделе мы создадим тот же проект с нуля, а не через шаблон. По ходу дела я объясню каждую часть кода и конфигурации. Готовы к испытанию?

Создание нового проекта

Начнём с создания обычного бинарного проекта Rust. Используйте следующую команду:

#![allow(unused)]
fn main() {
cargo new std_to_no_std
}

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

├── Cargo.toml
└── src
    └── main.rs

Наша цель — получить следующую структуру проекта:

├── build.rs
├── .cargo
│   └── config.toml
├── Cargo.toml
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs

Кросс-компиляция

Rust позволяет собирать бинарные файлы для операционных систем, отличных от той, которую вы сейчас используете. Это значит, что код можно писать в одной ОС, а собирать бинарники для совсем другой. Например, можно разрабатывать под Linux и собирать .exe-файлы, которые запускаются в Windows. Можно даже собирать под «голые» микроконтроллеры, такие как ESP32 или STM32. В этом разделе мы разберём, как это работает и что означают такие понятия, как target triple.

TL;DR

Для сборки под ESP32 нужно использовать target xtensa-esp32-none-elf.

cargo build --target xtensa-esp32-none-elf

Можно также задать target в .cargo/config.toml, чтобы не вводить его каждый раз.

Сборка для вашей системы

Допустим, мы работаем на компьютере с Linux. Когда вы выполняете обычную команду сборки, Rust компилирует код для текущей платформы, то есть в данном случае для Linux:

cargo build

Проверить, какой бинарный файл был создан, можно с помощью команды file:

file ./target/debug/std_to_no_std

Вывод будет примерно таким. Он говорит, что это 64-битный ELF-файл, динамически связанный и собранный под Linux.

./target/debug/std_to_no_std: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Build...

Кросс-компиляция под Windows

Теперь допустим, что вы хотите собрать бинарный файл под Windows, не покидая свою машину с Linux. Вот тут и пригодится кросс-компиляция.

Сначала нужно сообщить Rust о целевой платформе. Делать это нужно только один раз:

rustup target add x86_64-pc-windows-gnu

Это добавляет поддержку генерации 64-битных бинарников для Windows с помощью GNU-тулчейна (MinGW).

Теперь соберите проект снова, на этот раз указав цель:

cargo build --target x86_64-pc-windows-gnu

Вот и всё. Rust создаст Windows-файл .exe, хотя вы по-прежнему находитесь в Linux. Итоговый бинарник будет лежать в target/x86_64-pc-windows-gnu/debug/std_to_no_std.exe.

Тип файла можно проверить так:

file target/x86_64-pc-windows-gnu/debug/std_to_no_std.exe

Вывод будет примерно таким: 64-битный файл формата PE32+ для Windows.

target/x86_64-pc-windows-gnu/debug/std_to_no_std.exe: PE32+ executable (console) x86-64, for MS Windows

Что такое target triple?

Что же означает строка x86_64-pc-windows-gnu?

Это называется target triple («тройка» цели), и она точно сообщает компилятору, какой результат вам нужен. Обычно она имеет такой формат:

`<architecture>-<vendor>-<os>-<abi>`

Но шаблон не всегда соблюдается. Иногда часть ABI отсутствует. В других случаях может отсутствовать и вендор, или и вендор, и ABI. Структура бывает запутанной, и исключений хватает. Если хотите глубже разобраться во всех нюансах, посмотрите статью «What the Hell Is a Target Triple?» из списка ссылок ниже.

Разберём, что на самом деле означает этот target triple:

  • Архитектура (x86_64): 64-битная x86, то есть тип процессора, который используется в большинстве современных ПК. Её также называют AMD64 или x64.

  • Вендор (pc): по сути, это заглушка. В большинстве случаев он не очень важен. Для macOS вендором будет apple.

  • ОС (windows): сообщает Rust, что мы хотим собрать программу для Windows.

  • ABI (gnu): эта часть указывает Rust использовать GNU-тулчейн для сборки бинарника.

Ссылки

Компиляция для микроконтроллера

Теперь поговорим о встраиваемых системах. Компиляция Rust-кода для микроконтроллера работает немного иначе, чем на обычных настольных системах. Микроконтроллеры обычно не запускают полноценную операционную систему вроде Linux или Windows. Вместо этого они работают в минимальном окружении, часто вообще без ОС. Такое окружение называется bare-metal, то есть «голое железо».

Rust поддерживает такой режим через no_std. В обычных программах на Rust стандартная библиотека (std) отвечает за файловые системы, потоки, выделение памяти в куче и ввод-вывод. Но ничего этого нет на bare-metal микроконтроллере. Поэтому вместо std мы используем значительно меньшую библиотеку core, которая предоставляет только базовые строительные блоки.

ESP32 и std

Интересный момент: ESP32 действительно может работать со std, но только если использовать официальный фреймворк ESP-IDF. ESP-IDF предоставляет возможности, похожие на ОС, и благодаря этому на ESP32 можно использовать код на Rust, основанный на std.

В этой книге мы не будем использовать std. Мы сосредоточимся исключительно на bare-metal подходе с no_std.

Target triple для ESP32

Чтобы скомпилировать Rust-код для микроконтроллера, нужно указать особую цель (target). Для bare-metal разработки под ESP32 target triple выглядит так:

xtensa-esp32-none-elf

Разберём его по частям:

  • Архитектура (xtensa): ESP32 основан на архитектуре Xtensa, а не ARM или x86.
  • Вендор (esp32): указывает на конкретное семейство чипов от Espressif.
  • ОС (none): сообщает Rust, что операционная система не используется, то есть это bare-metal.
  • Формат (elf): эта четвёртая часть важна. Во многих целях четвёртый элемент — это ABI (например, gnu или msvc). Но здесь это не ABI, а формат объектных файлов. Это ELF (Executable and Linkable Format), который часто используется во встраиваемых системах.

Постойте… Вы можете спросить: мы же ни разу не использовали команду rustup target add для ESP32! Так получилось потому, что об этом позаботился инструмент espup. Мы уже настроили его в разделе о среде разработки. Дело в том, что добавить target ESP32 обычной командой rustup нельзя. Вместо этого espup устанавливает отдельный тулчейн Rust с именем «esp» (или любым другим именем, которое вы выберете), и в этом тулчейне уже есть поддержка target ESP32.

Конфигурация Cargo

В быстром старте вы могли заметить, что мы ни разу не передавали флаг --target при запуске cargo. Откуда же Cargo знал, для какой цели собирать? Дело в том, что target уже был задан в файле .cargo/config.toml.

Этот файл позволяет хранить настройки, связанные с cargo, включая цель сборки по умолчанию. Чтобы настроить его для ESP32, создайте папку .cargo в корне проекта и добавьте в неё файл config.toml со следующим содержимым:

[build]
target = "xtensa-esp32-none-elf"

Теперь не нужно передавать --target каждый раз: Cargo будет использовать эту настройку автоматически.

Тулчейн

Как только вы добавите target в файл config.toml и попытаетесь собрать проект командой cargo build, сборка перестанет работать. Вы увидите примерно такую ошибку:

'esp32' is not a recognized processor for this target (ignoring processor)
'esp32' is not a recognized processor for this target (ignoring processor)
error[E0463]: can't find crate for `std`
  |
  = note: the `xtensa-esp32-none-elf` target may not be installed
  = help: consider downloading the target with `rustup target add xtensa-esp32-none-elf`

For more information about this error, try `rustc --explain E0463`.
error: could not compile `std_to_no_std` (bin "std_to_no_std") due to 1 previous error

Сообщение говорит, что esp32 не распознан как процессор для этой цели, и жалуется, что target, возможно, не установлен. Но постойте: разве мы не установили всё с помощью espup? Установили.

Так что же произошло? По умолчанию Rust использует глобальный тулчейн вашей системы. Он ничего не знает о ESP32. Утилита espup установила отдельный тулчейн с именем «esp» (его тоже можно назвать иначе). Нам нужно лишь указать Rust использовать его.

В корне проекта создайте файл rust-toolchain.toml и добавьте в него:

[toolchain]
channel = "esp"

Вместо тулчейна «esp» по умолчанию можно использовать собственный. В этой книге я использую собственный тулчейн «book-1.0.0». Главное — убедитесь, что ваш тулчейн установлен и вы указываете его имя точно; иначе появится ошибка, что тулчейн не установлен.

Build-std

Попробуйте собрать проект снова. Сборка всё равно не пройдёт, но на этот раз ошибка выглядит немного иначе: теперь говорится, что не удаётся найти крейт для std.

error[E0463]: can't find crate for `std`
  |
  = note: the `xtensa-esp32-none-elf` target may not be installed
  = help: consider downloading the target with `rustup target add xtensa-esp32-none-elf`
  = help: consider building the standard library from source with `cargo build -Zbuild-std`

For more information about this error, try `rustc --explain E0463`.
error: could not compile `std_to_no_std` (bin "std_to_no_std") due to 1 previous error

Дело в том, что target ESP32 — это Tier 3 target. Это значит, что Rust не поставляет для него готовые стандартные библиотеки. Нам придётся собрать библиотеку core из исходников.

Чтобы это сделать, обновите файл .cargo/config.toml:

[unstable]
build-std = ["core"]

Попробуйте собрать проект ещё раз. Он всё равно не соберётся. Но не переживайте, мы уже близко. В следующем разделе исправим следующую группу ошибок.

no_std

В Rust есть две основные базовые библиотеки: std и core.

  • Крейт std — это стандартная библиотека. Она предоставляет такие возможности, как выделение памяти в куче, доступ к файловой системе, потоки и println!.

  • Крейт core — это минимальное подмножество. Он содержит только самые необходимые возможности Rust: базовые типы (Option, Result и др.), трейты и некоторые другие операции. Он не зависит от операционной системы или рантайма.

Если вы попытаетесь собрать проект на этом этапе, то получите целую кучу ошибок. Вот как это выглядит:

error[E0463]: can't find crate for `std`
  |
  = note: the `xtensa-esp32-none-elf` target may not support the standard library
  = note: `std` is required by `std_to_no_std` because it does not declare `#![no_std]`
  = help: consider building the standard library from source with `cargo build -Zbuild-std`

error: cannot find macro `println` in this scope
 --> src/main.rs:2:5
  |
2 |     println!("Hello, world!");
  |     ^^^^^^^

error: `#[panic_handler]` function required, but not found

For more information about this error, try `rustc --explain E0463`.

Ошибок здесь очень много. Давайте исправлять их по одной. Первая ошибка говорит, что целевая платформа может не поддерживать стандартную библиотеку. Это правда, мы уже это знаем. Проблема в том, что мы не сообщили Rust, что не хотим использовать std. Для этого и нужен атрибут no_std.

#![no_std]

Атрибут #![no_std] отключает использование стандартной библиотеки (std). Это почти всегда необходимо при разработке встраиваемых систем, где окружение обычно не предоставляет многих ресурсов (операционной системы, файловой системы или выделения памяти в куче), на которые рассчитывает стандартная библиотека.

В самом начале файла src/main.rs добавьте эту строку:

#![no_std]

Вот и всё. Теперь Rust знает, что проект будет использовать только core, а не std.

Println

Макрос println! входит в крейт std. Поскольку в нашем проекте std не используется, println! недоступен. Давайте уберём его из кода.

Теперь код должен выглядеть так:

#![no_std]
fn main() {
    
}

Этим исправлением мы устранили две ошибки и сократили список. Осталась ещё одна проблема, которую исправим в следующем разделе.

Ресурсы:

Обработчик паники (Panic Handler)

На этом этапе при сборке проекта вы получите такую ошибку:

error: `#[panic_handler]` function required, but not found

Когда программа на Rust паникует, обычно паника обрабатывается встроенным обработчиком из стандартной библиотеки. Но на предыдущем шаге мы добавили #![no_std], который говорит Rust не использовать стандартную библиотеку. Теперь обработчика паники по умолчанию нет.

В окружении no_std поведение при панике нужно определить самостоятельно, потому что нет операционной системы или рантайма, который перехватил бы управление, когда что-то пошло не так.

Исправить это можно, добавив собственный обработчик паники. Для этого создайте функцию с атрибутом #[panic_handler]. Функция должна принимать ссылку на PanicInfo, а её возвращаемый тип должен быть !, что означает, что функция никогда не возвращает управление.

Добавьте это в src/main.rs:

#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}
}

Крейты для паники

Существуют готовые крейты, которые предоставляют функцию-обработчик паники для проектов no_std. Один из простых и часто используемых — «panic_halt», который просто останавливает выполнение при панике.

#![allow(unused)]
fn main() {
use panic_halt as _;
}

Эта строка подключает обработчик паники из крейта. Теперь при панике программа просто останавливается и остаётся в бесконечном цикле.

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

#![allow(unused)]
fn main() {
use core::panic::PanicInfo;
use core::sync::atomic::{self, Ordering};

#[inline(never)]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {
        atomic::compiler_fence(Ordering::SeqCst);
    }
}
}

Можно либо использовать внешний крейт, либо написать обработчик паники вручную. Выбор за вами. Инструмент esp-template на самом деле записывает обработчик паники прямо в файл main.rs, а не подключает отдельный крейт.

Ресурсы:

no_main

Когда вы попытаетесь собрать проект на этом этапе, то получите ошибку о том, что функция main требует стандартной библиотеки. Что?! (Я удержался от соблазна вставить сюда мем с Мистером Бином, потому что не всем такие мемы нравятся.) Что же делать? Где вообще начинается программа?

Во встраиваемых системах мы не используем обычный fn main, который опирается на стандартную библиотеку. Вместо этого нужно сообщить Rust, что точку входа мы предоставим сами. Для этого используется атрибут no_main.

Атрибут #![no_main] указывает, что программа не будет использовать стандартную точку входа (fn main).

В самом начале файла src/main.rs добавьте эту строку:

#![no_main]

Объявление точки входа

Теперь, когда мы отказались от стандартной точки входа, нужно указать Rust, с какой функции начинать. Крейт esp-hal предоставляет для этого удобный атрибут: #[main].

Этот атрибут помечает вашу функцию как собственную точку входа. Функцию, помеченную #[main], вызовет обработчик сброса (reset handler) после инициализации RAM.

Сначала обновите Cargo.toml, добавив крейт esp-hal. Поскольку он поддерживает несколько чипов, нужно включить тот, который мы используем (в нашем случае esp32), с помощью feature-флага.

[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32"] }

Затем в main.rs настройте точку входа так:

use esp_hal::main;

#[main]
fn main() {}

Но есть ещё один шаг.

У функции входа должна быть особая сигнатура: она не должна возвращать управление. То есть возвращаемый тип должен быть ! (never type). Это говорит Rust, что ваша функция будет работать бесконечно, что типично для встраиваемых систем.

Поэтому обновим функцию main следующим образом:

#[main]
fn main() -> ! {
    loop {}
}

Уже всё?

Ура! Теперь попробуйте собрать проект: он должен скомпилироваться успешно.

Созданный бинарный файл можно проверить с помощью команды file:

file target/xtensa-esp32-none-elf/debug/std_to_no_std

Она покажет что-то вроде этого:

target/xtensa-esp32-none-elf/debug/std_to_no_std: ELF 32-bit LSB executable, Tensilica Xtensa, version 1 (SYSV), statically linked, with debug_info, not stripped

Как видите, бинарный файл собран под 32-битную цель Xtensa. Значит, базовая настройка для ESP32 работает.

Но уже всё? Пока нет. Мы прошли половину этапа: у нас есть корректный бинарный файл для ESP32, но перед запуском на реальном железе нужно сделать ещё кое-что.

Ресурсы:

Периферия

Прежде чем переходить к следующей части, давайте кратко разберёмся, что такое периферия.

Во встраиваемых системах периферия — это аппаратные компоненты, которые расширяют возможности микроконтроллера (MCU). Они позволяют MCU взаимодействовать с внешним миром: обрабатывать входы и выходы, выполнять обмен данными, работать со временем и многое другое.

Пока ЦП отвечает за выполнение логики программы, периферия берёт на себя основную работу по взаимодействию с железом, разгружая ЦП. Благодаря этому ЦП может сосредоточиться на важных задачах, а периферия выполняет специализированные функции самостоятельно или под минимальным контролем.

Разгрузка (offloading)

Offloading — это делегирование отдельных задач аппаратной периферии вместо того, чтобы выполнять их программно на ЦП. Это повышает производительность, снижает энергопотребление и позволяет выполнять операции параллельно. Например:

  • UART может отправлять и принимать данные в фоновом режиме с помощью DMA (Direct Memory Access), пока ЦП продолжает обрабатывать другую логику.
  • Таймер можно настроить так, чтобы он генерировал точные задержки или периодические прерывания без участия ЦП.
  • Контроллер ШИМ может непрерывно управлять мотором, не требуя от ЦП постоянно переключать выводы.

Offloading — ключевой принцип проектирования встраиваемых систем, позволяющий эффективно использовать ограниченную вычислительную мощность.

Распространённые типы периферии

Ниже перечислены наиболее распространённые типы периферии во встраиваемых системах:

ПериферияОписание
GPIO (General Purpose Input/Output)Цифровые выводы, которые можно настроить как входы или выходы для взаимодействия с внешними устройствами, такими как кнопки, светодиоды и датчики.
UART (Universal Asynchronous Receiver/Transmitter)Интерфейс последовательной связи для отправки и приёма данных между устройствами; часто используется для отладки и подключения модулей, например Bluetooth.
SPI (Serial Peripheral Interface)Высокоскоростной синхронный протокол для подключения микроконтроллера к периферии, например SD-картам, дисплеям и датчикам, по архитектуре «ведущий–ведомый» (master-slave).
I2C (Inter-Integrated Circuit)Двухпроводной протокол последовательной связи для подключения низкоскоростных устройств, таких как датчики и микросхемы памяти, к микроконтроллеру.
ADC (Analog-to-Digital Converter)Преобразует аналоговые сигналы от датчиков или других источников в цифровые значения, которые может обработать микроконтроллер.
PWM (Pulse Width Modulation)Формирует сигналы, которыми можно управлять подачей мощности; обычно используется для регулировки яркости светодиодов, управления скоростью мотора и работы сервоприводов.
TimerИспользуется для генерации задержек, измерения временных интервалов, подсчёта событий или запуска действий в заданное время.
RTC (Real-Time Clock)Отслеживает текущее время и дату даже при выключенном питании системы; обычно питается от батареи.

Периферия в Rust

Во встраиваемом Rust периферия используется по модели singleton. Одна из главных целей Rust — безопасность, и это касается и работы с железом. Чтобы никакие две части программы случайно не управляли одной и той же периферией одновременно, Rust обеспечивает эксклюзивное владение через этот подход.

Паттерн Singleton

Паттерн singleton гарантирует, что во всей программе существует только один экземпляр каждой периферии. Это позволяет избежать распространённых ошибок, когда несколько фрагментов кода одновременно пытаются изменить один и тот же аппаратный ресурс.

В esp-hal периферия тоже предоставляется в виде singleton. Но мы не будем вызывать Peripherals::take() напрямую. Вместо этого используем функцию esp_hal::init(config). Она выполняет базовую настройку системы и внутри вызывает Peripherals::take() за нас. Так мы получаем доступ ко всей периферии в безопасном и готовом к использованию виде.

Инициализация ESP HAL

Мы успешно подготовили бинарный файл для ESP32. Однако на этом этапе программа не делает абсолютно ничего. Оживим её: повторим ту же программу с миганием светодиода, которую мы видели в разделе «Быстрый старт».

Начните с добавления импорта:

#![allow(unused)]
fn main() {
use esp_hal::clock::CpuClock;
}

Теперь в начало функции main добавьте эту строку:

#![allow(unused)]
fn main() {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
}

Эта строка создаёт конфигурацию HAL по умолчанию и устанавливает тактовую частоту ЦП на максимум. Можно представлять это так: мы сообщаем чипу, с какой скоростью работать. Здесь мы просим его работать на максимальной скорости.

Инициализация периферии

Далее инициализируем периферию с помощью HAL:

#![allow(unused)]
fn main() {
let peripherals = esp_hal::init(config);
}

Эта функция настраивает систему: например, тактовую частоту ЦП и сторожевой таймер (watchdog timer). После этого она даёт нам доступ к периферии и тактовым сигналам чипа, чтобы мы могли использовать их в коде.

Светодиод

Следующий шаг — настроить встроенный светодиод на плате DevKit v1. Как это сделать? Для этого нужно управлять выводом GPIO.

GPIO

GPIO расшифровывается как General Purpose Input/Output (универсальный вход/выход). Это выводы микроконтроллера, которые могут либо считывать цифровые сигналы (вход), либо отправлять их (выход).

  • Хотите считать состояние кнопки? Используйте GPIO как вход.

  • Хотите включить светодиод? Используйте GPIO как выход.

  • Хотите общаться с датчиком или управлять мотором? Выводы GPIO — отправная точка.

GPIO — самый базовый и самый важный способ, которым ваша программа взаимодействует с внешним миром.

К какому выводу подключён встроенный светодиод?

Если вы используете голый чип ESP32, встроенного светодиода нет.

Но мы работаем с отладочной платой ESP32 DevKit v1. На ней есть встроенный светодиод, подключённый к GPIO2. Именно этот вывод мы будем использовать дальше.

Инициализация светодиода

Чтобы управлять встроенным светодиодом, настроим GPIO2 как выходной вывод.

Добавьте необходимые импорты:

#![allow(unused)]
fn main() {
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::time::{Duration, Instant};
}

Добавьте следующую строку внутри функции main:

#![allow(unused)]
fn main() {
let mut led = Output::new(peripherals.GPIO2, Level::High, OutputConfig::default());
}

Выражение peripherals.GPIO2 даёт нам доступ к выводу GPIO2. Мы задаём его начальное состояние Level::High, которое включает светодиод. OutputConfig::default() использует стандартные настройки, чтобы сделать вывод выходом. Обратите внимание: переменную led мы объявили изменяемой (mut), потому что позже будем менять её состояние. Метод toggle() переключает состояние светодиода между высоким и низким уровнем, благодаря чему светодиод мигает.

Главный цикл

Главный цикл непрерывно переключает состояние светодиода. Мы вызываем метод toggle() у выходного вывода светодиода, и тот переключается между высоким и низким уровнями. Между переключениями мы делаем задержку 500 миллисекунд, чтобы мигание было заметно.

#![allow(unused)]
fn main() {
loop {
    led.toggle();
    blocking_delay(Duration::from_millis(500));
}
}

Функция blocking_delay создаёт простую задержку, активно ожидая, пока не пройдёт заданное время. Она запоминает текущее время и постоянно проверяет, прошло ли нужное время:

#![allow(unused)]
fn main() {
fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}
}

Итоговый код

Вот полный код:

#![no_std]
#![no_main]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::main;
use esp_hal::time::{Duration, Instant};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

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

    let mut led = Output::new(peripherals.GPIO2, Level::High, OutputConfig::default());

    loop {
        led.toggle();
        blocking_delay(Duration::from_millis(500));
    }
}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Скрипт компоновщика (Linker Script)

Вы пробовали собрать проект снова? Да, теперь он не собирается. Что случилось?

Проблема в том, что компоновщик (linker) не знает, как разместить программу в памяти. Встраиваемым системам вроде ESP32 не нужна операционная система, которая управляла бы памятью, поэтому мы должны точно указать компоновщику, куда помещать код, данные и стек.

Для этого мы предоставим скрипт компоновщика. Это простой текстовый файл, который сообщает компоновщику, например:

  • Куда помещать код в памяти

  • Где начинается стек

  • Каков размер каждой области памяти

Мы будем использовать скрипт компоновщика под названием linkall.x. Он предоставляется esp-hal и содержит настройки размещения памяти, подходящие для ESP32.

Конфигурация Cargo

Проблему можно решить, добавив следующие строки в файл .cargo/config.toml. Однако этот способ мы использовать не будем. Вместо него применим файл build.rs.

[target.xtensa-esp32-none-elf]
rustflags = ["-C", "link-arg=-Tlinkall.x"]

Important

Не добавляйте аргумент компоновщика одновременно и в .cargo/config.toml, и в build.rs. Это вызовет конфликт и ошибку сборки. Используйте только один способ.

Файл build.rs

Инструмент esp-generate добавляет эту строку в файл build.rs. Он делает там и другие вещи, которые мы здесь не рассматриваем. Чтобы повторить его структуру, мы тоже добавим аргумент компоновщика через файл build.rs.

Теперь создайте новый файл build.rs в корне проекта (не внутри папки src/) и добавьте в него следующий код:

fn main() {
    // убедитесь, что linkall.x — последний скрипт компоновщика (иначе возможны проблемы с flip-link)
    println!("cargo:rustc-link-arg=-Tlinkall.x");
}

Теперь проект должен собираться:

cargo build

Последовательность запуска приложения

Прежде чем переходить к прошивке, нужно добавить в код ещё одну строку. Чтобы понять зачем, посмотрим, что происходит при включении микроконтроллера. В чипах Espressif наша программа не первая, кто запускается: до старта приложения выполняются два загрузчика.

Загрузчик ESP32 и дескриптор образа приложения

Загрузчик первого этапа (First Stage Bootloader) записан в ROM чипа на этапе производства, и изменить его нельзя. Загрузчик второго этапа (Second Stage Bootloader) как раз загружает ваше приложение и настраивает память. Стандартный загрузчик второго этапа — ESP-IDF Bootloader.

По умолчанию, когда вы прошиваете программу с помощью espflash, он автоматически включает предварительно собранный ESP-IDF Bootloader с настройками по умолчанию, так что собирать или настраивать его самостоятельно не нужно.

Дескриптор приложения

Во время загрузки ESP-IDF Bootloader читает секцию под названием «Application Descriptor», чтобы проверить вашу прошивку. Этот дескриптор содержит метаданные о приложении: информацию о версии, название проекта, время и дату компиляции, версию ESP-IDF и SHA256-хеш ELF-файла приложения.

Создавать этот дескриптор самостоятельно не нужно. Можно добавить такой крейт:

esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32"] }

Затем добавьте в файл main.rs этот макрос:

#![allow(unused)]
fn main() {
esp_bootloader_esp_idf::esp_app_desc!();
}

Этот макрос автоматически генерирует структуру дескриптора приложения, которую ESP-IDF Bootloader ожидает найти в каждом образе прошивки.

Прошивка

Нет, речь не о самом быстром человеке на планете. Мы говорим о записи нашего бинарного файла в микроконтроллер и его запуске.

Для этого мы воспользуемся полезным инструментом — espflash. Есть и другие инструменты, например probe-rs, но пока будем придерживаться espflash.

Прошить программу и открыть монитор можно такой командой:

#![allow(unused)]
fn main() {
espflash flash  --monitor --chip esp32 ./target/xtensa-esp32-none-elf/debug/std_to_no_std
}

Cargo run

Каждый раз вводить такую длинную команду неудобно. Упростим себе жизнь, обновив файл .cargo/config.toml. Мы можем сказать Cargo автоматически использовать espflash при выполнении cargo run.

Добавьте этот раздел в .cargo/config.toml:

[target.xtensa-esp32-none-elf]
runner = "espflash flash --monitor --chip esp32"

Теперь достаточно ввести:

cargo run --release

и программа будет прошита и запущена на ESP32.

Уф… Мы начали с бинарного файла для стандартной библиотеки и превратили его в бинарный файл no_std для ESP32. Наконец-то мы видим, как мигает светодиод.

К счастью, проходить всю эту настройку при каждом создании нового проекта не придётся. Именно поэтому такие инструменты, как esp-generate (или cargo-generate), так полезны во встраиваемом программировании: они настраивают все сложные части, чтобы можно было сразу перейти к написанию кода.

Широтно-импульсная модуляция (PWM)

В этом разделе мы разберёмся, что такое ШИМ (PWM) и зачем он нужен.

Цифровые и аналоговые сигналы

В цифровой схеме сигналы бывают либо высокими (например, 5 В или 3,3 В), либо низкими (0 В), и промежуточных значений нет. Благодаря этим двум различным состояниям цифровые сигналы идеально подходят для компьютеров и цифровых устройств: их легко хранить, считывать и передавать без потери точности.

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

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

Микроконтроллеры используют ШИМ, чтобы преодолеть этот разрыв.

Что такое ШИМ?

ШИМ (PWM — Pulse Width Modulation, широтно-импульсная модуляция) создаёт аналогоподобный сигнал, быстро переключая цифровой сигнал между включённым и выключенным состоянием. Среднее выходное напряжение можно регулировать, меняя длительность высокого уровня импульса, то есть коэффициент заполнения (duty cycle). Так можно имитировать непрерывный аналоговый уровень.

ШИМ светодиода

Коэффициент заполнения

Коэффициент заполнения (duty cycle) — это процент времени, в течение которого сигнал находится во включённом состоянии за один полный цикл.

Например:

  • Коэффициент заполнения 100% означает, что сигнал всегда включён.
  • Коэффициент заполнения 50% означает, что сигнал половину времени включён, а половину выключен.
  • Коэффициент заполнения 0% означает, что сигнал всегда выключен.

Ниже показана интерактивная симуляция. С помощью ползунков измените коэффициент заполнения и частоту и посмотрите, как меняются ширина импульса и яркость светодиода. Верхняя часть квадратного сигнала означает, что сигнал в высоком состоянии (включён), нижняя часть — что он в низком состоянии (выключен).

50%

Если менять коэффициент заполнения в симуляции от «низкого» к «высокому» и обратно, вы заметите, что светодиод словно плавно меняет яркость. Это и есть эффект диммирования.

Период и частота

Период — это общее время, за которое завершается один цикл «включено — выключено».

Частота ШИМ-сигнала — это количество циклов, которые он совершает за одну секунду, измеряется в герцах (Гц). Частота обратно пропорциональна периоду. Поэтому более высокая частота означает более короткий период и более быстрое переключение между высоким и низким уровнями.

\[ \text{Frequency (Hz)} = \frac{1}{\text{Period (s)}} \]

Если период равен 1 секунде, то частота будет 1 Гц.

\[ 1 \text{Hz} = \frac{1 \text{ cycle}}{1 \text{ second}} = \frac{1}{1 \text{ s}} \]

Например, если период равен 20 мс (0,02 с), частота будет 50 Гц.

\[ \text{Frequency} = \frac{1}{20 \text{ ms}} = \frac{1}{0.02 \text{ s}} = 50 \text{ Hz} \]

Расчёт количества циклов по частоте

Формула для расчёта количества циклов:
\[ \text{Cycle Count} = \text{Frequency (Hz)} \times \text{Total Time (seconds)} \]

Если ШИМ-сигнал имеет частоту 50 Гц, это значит, что он совершает 50 циклов за одну секунду.

В следующей главе мы подробно разберём ШИМ и таймер.

ШИМ в деталях

Работа таймера

Таймер играет ключевую роль в генераторе ШИМ. Он считает от нуля до заданного максимального значения (хранящегося в регистре), затем сбрасывается и начинает цикл заново. Этот подсчёт определяет длительность одного полного цикла, которая называется периодом.

Значение сравнения (Compare Value)

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

Разрешение ШИМ (PWM Resolution)

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

Таймер считает от 0 до максимального значения, которое зависит от разрешения. Чем выше разрешение, тем точнее можно настраивать коэффициент заполнения.

Для системы с n битами разрешения таймер может считать от 0 до \(2^n - 1\), что даёт \(2^n\) возможных уровней коэффициента заполнения.

Например:

  • 8-битное разрешение позволяет таймеру считать от 0 до 255, то есть даёт 256 возможных уровней коэффициента заполнения.
  • 10-битное разрешение позволяет таймеру считать от 0 до 1023, то есть даёт 1024 возможных уровня коэффициента заполнения.

Более высокое разрешение обеспечивает более точное управление коэффициентом заполнения, но при этом таймеру нужно считать больше значений в пределах того же периода, что может снизить частоту или потребовать больше вычислительной мощности. По сути, разрешение определяет, сколько различных значений коэффициента заполнения можно задать: чем больше битов, тем тоньше настройка.

Симуляция

В этой симуляции вы можете менять разрешение ШИМ в битах и коэффициент заполнения. Изменение разрешения в битах увеличивает максимальное значение счётчика, но остаётся в пределах периода (на коэффициент заполнения оно не влияет). Изменение коэффициента заполнения соответственно меняет состояния «включено» и «выключено», но тоже остаётся в пределах периода.

Связь между коэффициентом заполнения, частотой и разрешением

Эта диаграмма иллюстрирует связь между коэффициентом заполнения, частотой, периодом, шириной импульса и разрешением. На первый взгляд она может показаться сложной, но если разобрать её по частям, всё становится яснее.

В этом примере разрешение таймера равно 4 битам, то есть таймер считает от 0 до 15. Когда таймер достигает максимального значения, срабатывает прерывание переполнения (синяя стрелка), и счётчик сбрасывается в 0. Время, которое таймеру нужно, чтобы досчитать от 0 до максимума, называется «периодом».

ШИМ

Коэффициент заполнения задан равным 50%, то есть сигнал остаётся в высоком состоянии половину периода. На каждом шаге счёта таймер сравнивает текущее значение счётчика со значением сравнения для коэффициента заполнения. Когда значение счётчика превышает это значение сравнения (жёлтая стрелка), сигнал переходит из высокого состояния в низкое. Это вызывает прерывание сравнения, которое сигнализирует о смене состояния.

Время, в течение которого сигнал находится в высоком состоянии, называется шириной импульса.

Контроллер ШИМ для светодиодов (LEDC)

В ESP32 есть контроллер ШИМ для светодиодов (LED PWM Controller, LEDC), который формирует сигналы ШИМ для управления светодиодами, например для эффекта плавного изменения яркости. Однако его возможности не ограничиваются светодиодами: его можно использовать и для других задач. Если вы не знакомы с ШИМ (PWM), рекомендую сначала прочитать введение в ШИМ здесь.

LEDC включает 16 независимых генераторов ШИМ и поддерживает максимальное разрешение коэффициента заполнения 20 бит. Шестнадцать каналов ШИМ делятся на два типа: 8 высокоскоростных каналов и 8 низкоскоростных.

Note

Высокоскоростные и низкоскоростные каналы: высокоскоростные каналы используют аппаратную логику, которая автоматически меняет коэффициент заполнения ШИМ без рывков, обеспечивая плавную работу. Низкоскоростные каналы, напротив, полагаются на программное управление и меняют коэффициент заполнения вручную.

Контроллер ШИМ может автоматически постепенно увеличивать или уменьшать коэффициент заполнения, обеспечивая плавное затухание и нарастание яркости без участия процессора.

Источник тактирования (Clock Source)

Источник тактирования в микроконтроллере — это что-то вроде сердцебиения системы. Он задаёт регулярные «тики», благодаря которым микроконтроллер отслеживает время и координирует свои задачи. В ESP32 для управления таймерами можно использовать разные источники тактирования.

ШИМ Изображение взято из технического справочного руководства ESP32

Доступно четыре высокоскоростных модуля тактирования, которые можно назначить высокоскоростным каналам. Высокоскоростные таймеры ESP32 могут тактироваться от таких источников, как REF_TICK или APB_CLK. В библиотеке esp-hal эти таймеры представлены перечислением timer::Number, которое включает Timer0, Timer1, Timer2 и Timer3.

Также доступно четыре низкоскоростных модуля тактирования, которые можно назначить низкоскоростным каналам. Эти таймеры могут тактироваться от REF_TICK или SLOW_CLOCK. Источником SLOW_CLOCK может быть либо APB_CLK (80 МГц), либо внутренний генератор 8 МГц; выбор между ними задаётся настройкой LEDC_APB_CLK_SEL.

В esp-hal также определены два перечисления: одно для источника тактирования высокоскоростных таймеров (HSClockSource) и другое для низкоскоростных (LSClockSource). Сейчас в каждом из них есть только один вариант: APBClk.

Подробнее см. главу LED PWM Controller (LEDC) в техническом справочном руководстве ESP32.

Расчёт разрешения ШИМ

В ESP-HAL при настройке объекта таймера нужно указать и разрешение коэффициента заполнения, и частоту. Поэтому важно понимать, как по заданной частоте вычислить разрешение и как по заданному разрешению определить частоту.

Эти формулы взяты из технического справочного руководства ESP32, но имена переменных упрощены.

Формула частоты ШИМ-сигнала

Частоту ШИМ-сигнала \( f_{\text{pwm}} \) можно вычислить по формуле:

\[ f_{\text{pwm}} = \frac{f_{\text{LEDC_CLK}}}{\text{clock_divider} \cdot 2^{\text{res_bits}}} \]

Где:

  • \( f_{\text{LEDC_CLK}} \) — частота источника тактирования для таймера ШИМ (например, APB_CLK, RC_FAST_CLK, REF_TICK).
  • \( \text{clock_divider} \) — коэффициент деления для источника тактирования.
  • \( \text{res_bits} \) — разрешение коэффициента заполнения в битах.

Формула разрешения ШИМ

Это формула, выведенная из предыдущей, для расчёта нужного разрешения коэффициента заполнения. \[ \text{res_bits} = \log_2 \left( \frac{f_{\text{LEDC_CLK}}}{f_{\text{pwm}} \cdot \text{clock_divider}} \right) \]

Эта формула даёт разрешение в битах, то есть количество дискретных уровней, доступных для коэффициента заполнения ШИМ-сигнала.

Расчёт максимального разрешения

Максимальное разрешение достигается, когда делитель тактирования (\( \text{clock_divider} \)) равен 1, то есть деление тактового сигнала не применяется. Оно вычисляется так:

\[ \text{Highest Resolution} = \log_2 \left( \frac{f_{\text{LEDC_CLK}}}{f_{\text{pwm}} \cdot 1} \right) \]

Этот расчёт даёт максимальное количество битов, которое можно использовать для коэффициента заполнения при заданной частоте тактирования и частоте ШИМ-сигнала.

Пример:

Для ШИМ-сигнала 1 кГц при APB_CLK 80 МГц:

\[ \text{Highest Resolution} = \log_2 \left( \frac{80,000,000}{1,000 \cdot 1} \right) = \log_2(80,000) \approx 16 \]

Таким образом, максимальное разрешение для ШИМ-сигнала 1 кГц при тактировании 80 МГц составляет 16 бит.

Расчёт минимального разрешения

Согласно даташиту, коэффициент деления находится в диапазоне от 1 до 1023. Минимальное разрешение рассчитывается при максимальном значении делителя тактирования. В этом случае делитель равен \( 1023 + \frac{255}{256} \). Минимальное разрешение вычисляется так:

\[ \text{Lowest Resolution} = \log_2 \left( \frac{f_{\text{LEDC_CLK}}}{f_{\text{pwm}} \cdot \left( 1023 + \frac{255}{256} \right)} \right) \]

Этот расчёт даёт минимальное количество битов, необходимое для управления коэффициентом заполнения на заданной частоте ШИМ-сигнала.

Пример:

Для того же ШИМ-сигнала 1 кГц при APB_CLK 80 МГц:

\[ \text{Lowest Resolution} = \log_2 \left( \frac{80,000,000}{1,000 \cdot 1023.996} \right) = \log_2 \left( \frac{80,000,000}{1,023,996} \right) \approx 6.28 \]

Таким образом, минимальное разрешение для ШИМ-сигнала 1 кГц при тактировании 80 МГц составляет 7 бит.

Часто используемые частоты ШИМ и разрешения

Эта таблица из даташита кратко показывает максимальное и минимальное разрешения для распространённых частот ШИМ с разными источниками тактирования:

Источник тактированияЧастота ШИММаксимальное разрешение (бит)Минимальное разрешение (бит)
APB_CLK (80 МГц)1 кГц167
APB_CLK (80 МГц)5 кГц134
APB_CLK (80 МГц)10 кГц123
RC_FAST_CLK (8 МГц)1 кГц123
RC_FAST_CLK (8 МГц)2 кГц112
REF_TICK (1 МГц)1 кГц91

Модулятор ШИМ для управления моторами (MCPWM)

В ESP32 есть два периферийных блока ШИМ: контроллер для светодиодов и MCPWM. В этой главе мы познакомимся с MCPWM.

MCPWM предназначен для управления моторами и мощностью. В ESP32 есть два блока MCPWM: MCPWM0 и MCPWM1. На схеме ниже показан обзор одного модуля MCPWM.

ШИМ

Каждый MCPWM содержит предделитель (делитель тактовой частоты), три таймера, три оператора, обработчик ошибок (fault handler) и модуль захвата (capture).

Предделитель (Prescaler)

Предделитель используется для уменьшения базовой тактовой частоты перед тем, как она будет применена к ШИМ-сигналу. MCPWM работает от тактового сигнала частотой 160 МГц, то есть тикает 160 миллионов раз в секунду. Этот сигнал служит базовой частотой для модуля MCPWM. Однако предделитель делит эту базовую частоту, фактически уменьшая частоту, которую использует ШИМ-сигнал.

Чтобы вычислить длительность одного такта, возьмём обратную величину частоты:

\[ \text{Period} = \frac{1}{160 \times 10^6} \text{ s} = 0.00000000625 \text{ s} = 6.25 \text{ ns} \]

Это значит, что каждый такт длится 6,25 наносекунды (нс).

Регистр PWM_CLK_PRESCALE (8 бит) в ESP32 позволяет настроить базовую частоту, разделив её. Когда предделитель применён к базовой частоте, она фактически уменьшается. Результирующий период ШИМ вычисляется по формуле:

\[ \text{PWM Clock Period} = 6.25 \text{ns} \times (\text{Prescaler Value} + 1) \]

Пример:

Допустим, вы установили значение предделителя 159.

\[ \text{PWM Clock Period} = 6.25 \text{ ns} \times (159 + 1) = 6.25 \text{ ns} \times 160 = 1000 \text{ ns} \]

Чтобы вычислить частоту ШИМ, возьмём обратную величину периода:

\[ \text{PWM Frequency} = \frac{1}{\text{PWM Clock Period}} = \frac{1}{1000 \text{ ns}} = 1 \text{ MHz} \]

Таким образом, при значении предделителя 159 ШИМ-сигнал будет иметь период 1000 нс и частоту 1 МГц.

При максимальном значении предделителя 255 (так как это 8-битное значение) можно получить частоту ШИМ:

\[ \text{PWM Frequency} = \frac{1}{6.25 \text{ ns} \times (255 + 1)} = \frac{1}{1600 \text{ ns}} = 625 \text{ KHz} \]

В esp-hal

Структура PeripheralClockConfig в esp-hal отвечает за конфигурацию тактирования MCPWM. Она предлагает две функции инициализации: с предделителем или с частотой.

#![allow(unused)]
fn main() {
// Эта функция автоматически вычисляет предделитель для получения 1 МГц
let clock_cfg = PeripheralClockConfig::with_frequency(1.MHz()).unwrap();
}

Таймер (Timer)

Таймер отвечает за подсчёт до заданного значения (называемого «периодом»), после чего он сбрасывается и начинает считать заново. Это помогает контролировать временные параметры или частоту выходного сигнала. У каждого таймера есть 8-битный предделитель тактовой частоты.

У таймера 16-битный счётчик, который может работать в трёх режимах. В esp-hal они представлены перечислением PwmWorkingMode:

  • PwmWorkingMode::Increase: счёт вверх — таймер начинает с нуля и считает вверх до значения периода, после чего сбрасывается.
  • PwmWorkingMode::Decrease: счёт вниз — таймер начинает со значения периода и считает вниз до нуля, после чего сбрасывается.
  • PwmWorkingMode::UpDown: счёт вверх-вниз — таймер попеременно считает то вверх, то вниз, создавая симметричный цикл.

В esp-hal

В esp-hal есть структура TimerClockConfig, которую можно инициализировать через экземпляр конфигурации тактирования. Нужно вызвать функцию timer_clock_with_frequency (или timer_clock_with_prescaler) с аргументами:

  1. Количество тактов в периоде
  2. Режим работы ШИМ
  3. Целевая частота

Пример:

Чтобы получить частоту 50 Гц (20 мс, или 0,02 с) при базовой тактовой частоте 1 МГц (предполагаем, что clock_cfg инициализирован на 1 МГц), нужно вычислить количество тактов для периода 0,02 с (20 мс) при 1 МГц. Расчёт:

\[ \text{Counts} = 1,000,000 \text{ Hz} \times 0.02 \text{ s} = 20,000 \]

Теперь можно вызвать функцию для инициализации таймера ШИМ:

#![allow(unused)]
fn main() {
let timer_clock_cfg = clock_cfg
    .timer_clock_with_frequency(19_999, PwmWorkingMode::Increase, 50.Hz())
    .unwrap();
}

Мы передаём 19 999, а не 20 000, потому что функция внутренне прибавляет к нему 1, и в итоге получается 20 000.

Оператор (Operator)

Оператор ШИМ формирует нужную выходную форму сигнала, используя временные ориентиры от таймера ШИМ. У каждого оператора ШИМ два выхода, PWMxA и PWMxB, и для них можно настроить мёртвое время (dead-time) как для нарастающего, так и для спадающего фронта.

Мы настроили периферию MCPWM0 и выбрали operator0 для использования выхода PWMxA:

#![allow(unused)]
fn main() {
let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg);
let mut pwm_pin = mcpwm
.operator0
.with_pin_a(peripherals.GPIO33, PwmPinConfig::UP_ACTIVE_HIGH);
}

Здесь UP_ACTIVE_HIGH задаёт действие ШИМ, а SYNC_ON_ZERO — метод обновления ШИМ. Что это значит? Выходной сигнал будет установлен в высокий уровень, когда значение счётчика таймера меньше значения временной метки (timestamp). Затем новая временная метка будет применена, когда таймер достигнет нуля.

Функция set_timestamp в esp-hal

Функция set_timestamp задаёт момент, в который ШИМ-сигнал должен измениться. Когда таймер достигает переданного в функцию значения, сигнал обновляется в соответствии с настроенным PwmUpdateMethod. В нашем примере в операторе настроен UP_ACTIVE_HIGH, поэтому выход остаётся в высоком уровне, пока таймер не достигнет значения временной метки. Как только метка достигнута, сигнал переходит в низкий уровень до конца цикла ШИМ.

Например, если установить значение временной метки 500, сигнал останется в высоком уровне, пока счётчик таймера не достигнет 500. Когда он достигнет 500, сигнал будет в низком уровне для оставшихся 19 500 тактов (20 000 − 500).

#![allow(unused)]
fn main() {
pwm_pin.set_timestamp(500);
}

LEDC или MCPWM

LEDC (LED Controller) создан для управления светодиодами, но его также можно использовать для общих задач ШИМ. Поэтому он идеально подходит для простых задач, таких как регулировка яркости светодиода или простое управление мотором.

MCPWM (Motor Control PWM) создан специально для управления моторами и имеет продвинутые функции, такие как вставка мёртвого времени и обработка ошибок, поэтому лучше подходит для точного и сложного управления моторами.

Типичные сценарии использования периферии MCPWM согласно справочному документу API:

  • Цифровое управление моторами, например коллекторными и бесколлекторными двигателями постоянного тока, RC-сервоприводами
  • Цифровое преобразование энергии на основе импульсных источников (switch mode)
  • Силовой ЦАП (Power DAC), где коэффициент заполнения соответствует аналоговому значению ЦАП
  • Вычисление длительности внешнего импульса и преобразование её в другие аналоговые величины, например в скорость или расстояние
  • Генерация сигналов Space Vector PWM (SVPWM) для векторного управления (Field Oriented Control, FOC)

Для управления любительским сервоприводом вроде SG90 LEDC прост и достаточен, но MCPWM тоже можно использовать.

Ссылки

Эффект плавного затухания светодиода

В этом разделе мы научимся создавать эффект затухания (уменьшения и увеличения яркости) светодиода с помощью ESP32. Сначала сделаем затухание встроенного светодиода, подключённого к GPIO 2.

Предварительные требования

Чтобы создать эффект затухания, мы используем технику ШИМ (PWM — Pulse Width Modulation, широтно-импульсная модуляция).

Рекомендуется прочитать эти главы:

Мы будем постепенно увеличивать коэффициент заполнения ШИМ, чтобы повышать яркость, а затем постепенно уменьшать его, чтобы снижать яркость светодиода. Так на светодиоде получается эффект затухания.

Глаз

« Подойдите ближе… Ещё ближе…

Чем больше вам кажется, что вы видите, тем легче вас обмануть…

Ведь что такое зрение?.. Вы смотрите, но на самом деле вы фильтруете, интерпретируете, ищете смысл… »

В ШИМ мы задаём процент, который определяет, сколько времени сигнал остаётся в высоком (ВКЛ) и низком (ВЫКЛ) состоянии в каждом цикле. Например, при коэффициенте заполнения 50% сигнал включён половину времени и выключен другую половину, поэтому светодиод горит только половину цикла.

А теперь самое интересное: когда переключения происходят очень быстро, наши глаза не успевают за ними. Вместо мигания мы видим просто изменение яркости! Чем дольше светодиод включён, тем ярче он кажется; чем меньше времени он включён, тем тусклее. Это как обмануть мозг, заставив его думать, что светодиод плавно меняет яркость. ШИМ — маг электроники!

Пишем код на Rust для эффекта затухания светодиода на ESP32

Теперь начинается самое интересное: давайте займёмся программированием!

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

Этот шаг вы уже выполнили в разделе «Быстрый старт».

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 led-fader

Откроется экран с вопросом о выборе опций. В последних версиях esp-hal для ledc нужно явно включить нестабильные возможности.

  • Поэтому выберите опцию «Enable unstable HAL features» («Включить нестабильные возможности HAL»).

Затем сохраните настройки, нажав на клавиатуре «s».

Tip

В объяснении я не буду приводить необходимые импорты: они не особенно интересны и мало что объясняют. Всегда можно обратиться к разделу «Полный код» ниже или склонировать проект для сверки.

Автоматически сгенерированный код

Когда вы создаёте проект с помощью esp-generate, он настраивает базовую структуру, конфигурирует тактовую частоту ЦП и включает стандартный код для периферии, так что вам не нужно набирать его каждый раз. В этой книге мы всегда будем создавать проекты с помощью esp-generate и строить поверх них.

#![allow(unused)]
fn main() {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
}

Вывод светодиода

Далее берём нужный GPIO из экземпляра peripherals. В нашем случае мы управляем встроенным светодиодом DevKit, подключённым к GPIO 2.

#![allow(unused)]
fn main() {
let led = peripherals.GPIO2;
}

Настройка ШИМ

В этом упражнении мы будем использовать низкоскоростной канал ШИМ. Сначала нужно задать источник тактирования. Библиотека esp-hal определяет перечисление LSGlobalClkSource для низкоскоростного источника тактирования, в котором сейчас есть только одно значение: APBClk.

#![allow(unused)]
fn main() {
ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);
}

Далее настраиваем таймер. Так как мы используем низкоскоростной канал ШИМ, нам, очевидно, нужен низкоскоростной таймер. Также нужно указать, какой низкоскоростной таймер использовать (от 0 до 3).

#![allow(unused)]
fn main() {
let mut lstimer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0);
}

Перед использованием таймера нужно выполнить ещё несколько настроек. Мы зададим частоту 24 кГц. Для этой частоты при тактировании от APB формула даёт максимальное разрешение 12 бит и минимальное 2 бита. В esp-hal для этой частоты используется 5-битное разрешение ШИМ, и мы будем использовать то же.

#![allow(unused)]
fn main() {
lstimer0
    .configure(timer::config::Config {
        duty: timer::config::Duty::Duty5Bit,
        clock_source: timer::LSClockSource::APBClk,
        frequency: Rate::from_khz(24),
    })
    .unwrap();
}

Каналы ШИМ

Далее настраиваем канал ШИМ. Мы будем использовать channel0 и настроим его с выбранным таймером и начальным коэффициентом заполнения «10%». Кроме того, зададим конфигурацию вывода как PushPull.

#![allow(unused)]
fn main() {
let mut channel0 = ledc.channel(channel::Number::Channel0, led);
channel0
    .configure(channel::config::Config {
        timer: &lstimer0,
        duty_pct: 10,
        drive_mode: DriveMode::PushPull,
    })
    .unwrap();
}

Затухание

В esp-hal есть функция start_duty_fade, которая упрощает нашу работу. В противном случае нам пришлось бы вручную увеличивать и уменьшать коэффициент заполнения в цикле через равные промежутки времени. Эта функция плавно меняет коэффициент заполнения от одного процентного значения к другому. Она принимает и третий параметр, который задаёт, сколько времени должен занять переход от одного коэффициента заполнения к другому.

#![allow(unused)]
fn main() {
channel0.start_duty_fade(0, 100, 1000).unwrap();
}

Мы будем запускать это в цикле и использовать ещё одну функцию HAL, is_duty_fade_running: она возвращает логическое значение, показывая, завершено ли затухание.

#![allow(unused)]
fn main() {
while channel0.is_duty_fade_running() {}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::DriveMode;
use esp_hal::main;
use esp_hal::time::Rate;

// Для LEDC
use esp_hal::ledc::channel::ChannelIFace;
use esp_hal::ledc::timer::TimerIFace;
use esp_hal::ledc::{LSGlobalClkSource, Ledc, LowSpeed, channel, timer};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let led = peripherals.GPIO2;
    // let led = peripherals.GPIO5;

    let mut ledc = Ledc::new(peripherals.LEDC);
    ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);
    let mut lstimer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0);
    lstimer0
        .configure(timer::config::Config {
            duty: timer::config::Duty::Duty5Bit,
            clock_source: timer::LSClockSource::APBClk,
            frequency: Rate::from_khz(24),
        })
        .unwrap();

    let mut channel0 = ledc.channel(channel::Number::Channel0, led);
    channel0
        .configure(channel::config::Config {
            timer: &lstimer0,
            duty_pct: 10,
            drive_mode: DriveMode::PushPull,
        })
        .unwrap();

    loop {
        channel0.start_duty_fade(0, 100, 1000).unwrap();
        while channel0.is_duty_fade_running() {}
        channel0.start_duty_fade(100, 0, 1000).unwrap();
        while channel0.is_duty_fade_running() {}
    }
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку led-fader.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/led-fader

Прошивка

Как только вы прошьёте код в ESP32, вы должны увидеть эффект затухания на встроенном светодиоде.

#![allow(unused)]
fn main() {
cargo run --release
}

Использование внешнего светодиода

Тот же эффект затухания можно сделать и с внешним светодиодом.

Аппаратные требования

  • Внешний светодиод
  • Резистор (330 Ом)
  • Провода-перемычки (необязательно)
  • Макетная плата (необязательно). Возможно, понадобятся две макетные платы, чтобы уместить ESP32 DevKit, так как он довольно широкий. Я купил две маленькие макетки и поставил по одной стороне ESP32 на каждую.

Схема

  • Подключите анод (более длинная ножка) внешнего светодиода к GPIO 5 ESP32 через резистор 330 Ом.
  • Подключите катод (более короткая ножка) светодиода к выводу земли (GND) ESP32.

Изменения в коде

В коде нужно лишь поменять номер GPIO с 2 на 5.

#![allow(unused)]
fn main() {
let led = peripherals.GPIO5;
}

Высокоскоростной канал

Менять одну строку — не интересно. На этот раз используем высокоскоростной канал. Для этого нужно передать структуру HighSpeed и обновить источник тактирования, использовав перечисление HSClockSource.

#![allow(unused)]
fn main() {
let ledc = Ledc::new(peripherals.LEDC);

let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0);
hstimer0
    .configure(timer::config::Config {
        duty: timer::config::Duty::Duty5Bit,
        clock_source: timer::HSClockSource::APBClk,
        frequency: Rate::from_khz(24),
    })
    .unwrap();
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку led-highfader.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/led-highfader

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::DriveMode;
use esp_hal::main;
use esp_hal::time::Rate;

// Для LEDC
use esp_hal::ledc::channel::ChannelIFace;
use esp_hal::ledc::timer::TimerIFace;
use esp_hal::ledc::{HighSpeed, Ledc, channel, timer};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    // let led = peripherals.GPIO2;
    let led = peripherals.GPIO5;

    let ledc = Ledc::new(peripherals.LEDC);

    let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0);
    hstimer0
        .configure(timer::config::Config {
            duty: timer::config::Duty::Duty5Bit,
            clock_source: timer::HSClockSource::APBClk,
            frequency: Rate::from_khz(24),
        })
        .unwrap();

    let mut channel0 = ledc.channel(channel::Number::Channel0, led);
    channel0
        .configure(channel::config::Config {
            timer: &hstimer0,
            duty_pct: 10,
            drive_mode: DriveMode::PushPull,
        })
        .unwrap();

    loop {
        channel0.start_duty_fade(0, 100, 1000).unwrap();
        while channel0.is_duty_fade_running() {}
        channel0.start_duty_fade(100, 0, 1000).unwrap();
        while channel0.is_duty_fade_running() {}
    }
}

Async

Асинхронное программирование позволяет задачам выполняться параллельно, не блокируя друг друга. Во встраиваемых системах это даёт микроконтроллеру возможность обрабатывать несколько задач, например считывать данные с датчиков и управлять другой периферией, не дожидаясь завершения каждой из них. Подробнее об этом можно прочитать в книге Asynchronous Programming in Rust.

Embassy

До сих пор мы работали с кодом, который выполняется в блокирующем режиме. Это значит, что когда мы просим программу что-то сделать, например подождать некоторое время или дождаться нажатия кнопки, процессор останавливается и ждёт, пока задача не завершится, и только потом продолжает работу. Это просто для понимания и хорошо работает в небольших программах, но становится ограничением, когда нужно обрабатывать несколько задач одновременно, например считывать датчик и одновременно ожидать ввода, не блокируя друг друга.

Здесь на помощь приходит Embassy. Embassy — это асинхронный рантайм, созданный для встраиваемых систем. Он позволяет писать неблокирующий код с помощью возможностей async и await языка Rust. Вместо того чтобы ждать и тратить процессорное время впустую, задачи могут приостанавливаться и давать поработать другим. Так процессор используется лучше, а приложения становятся отзывчивее и экономичнее по энергии. Embassy можно использовать с ESP32, Pico и другими микроконтроллерами.

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

HAL

Embassy предоставляет асинхронные Hardware Abstraction Layers (HAL) для нескольких семейств микроконтроллеров. Они предлагают безопасный и идиоматичный API на Rust, так что вы можете работать с железом, не имея дела с низкоуровневыми регистрами.

Официальные HAL включают embassy-stm32 (STM32), embassy-nrf (nRF52/53/54/91), embassy-rp (RP2040) и embassy-mspm0 (TI MSPM0). Embassy также работает с HAL от сообщества, например esp-hal (ESP32), ch32-hal (CH32V), mpfs-hal (PolarFire) и py32-hal (Puya PY32), что упрощает написание переносимого асинхронного кода для множества платформ.

Батарейки в комплекте (Batteries included)

Embassy поставляется со множеством встроенных возможностей, которые упрощают разработку встраиваемых систем. Например, в него входят embassy-time для таймеров и задержек, embassy-net для поддержки сети, embassy-usb для создания USB-устройств и многое другое.

ESP RTOS

В экосистеме esp-hal есть крейт esp-rtos, который обеспечивает интеграцию между esp-hal и асинхронным фреймворком Embassy.

Реализация RTOS (Real-Time Operating System, операционная система реального времени) для esp-hal. Этот крейт предоставляет рантайм, необходимый для выполнения асинхронного кода поверх esp-hal, и реализует возможности (потоки, очереди и т. д.), которые требуются для esp-radio.

Полезные ресурсы

Мигание светодиодом с ESP RTOS (Embassy) на ESP32 на Rust

Давайте заново настроим проект мигания, но уже с поддержкой Embassy.

Создание проекта

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 blinky-embassy

На этот раз нужно выбрать «Add Embassy framework support» («Добавить поддержку фреймворка Embassy»). Поскольку для этого требуется нестабильная функция, сначала выберите «Enable unstable HAL features» («Включить нестабильные возможности HAL»), а затем — поддержку Embassy. Наконец, нажмите «s», чтобы сохранить сгенерированный проект с поддержкой Embassy.

Ключевые моменты

Если присмотреться, функция main теперь помечена как async, и в коде есть и другие изменения. Однако основная логика мигания светодиодом осталась прежней.

Ключевое дополнение — вот эта часть:

#![allow(unused)]
fn main() {
let timg0 = TimerGroup::new(peripherals.TIMG0);
esp_rtos::start(timg0.timer0);
}

Она настраивает таймер, который нужен Embassy для обработки асинхронных задач, например задержек. Мы создаём группу таймеров на аппаратном таймере TIMG0, затем передаём один из его таймеров в esp_rtos::start, чтобы Embassy использовал его для операций, связанных со временем.

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::timer::timg::TimerGroup;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(_spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    let mut led = Output::new(peripherals.GPIO2, Level::High, OutputConfig::default());

    loop {
        led.toggle();
        Timer::after(Duration::from_secs(1)).await;
    }
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку async-projects/blinky-embassy.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/async-projects/blinky-embassy

Пищалка (Buzzer)

В этом разделе мы займёмся интересными экспериментами с buzzer (пищалкой).

  • Активная или пассивная пищалка
  • Провода-перемычки:

Введение в пищалку

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

Активная и пассивная пищалка

Активная пищалка:

  • Встроенный генератор: активная пищалка содержит внутренний генератор, который сам создаёт тон при подаче питания. Определить, активная ли у вас пищалка, можно просто: подключите её напрямую к батарейке, и она издаст звук.

  • Проще в использовании: не нужно заботиться о генерации конкретных частот, так как пищалка делает это внутри.

  • Тон: обычно выдаёт один тон, то есть сигнал фиксированной частоты. активная пищалка

  • Как определить: обычно сверху белый корпус, а снизу гладкое чёрное покрытие. При прямом подключении к батарейке она издаёт звук.

Пассивная пищалка:

  • Нужен внешний сигнал: пассивная пищалка требует внешнего сигнала, чтобы издавать звук. У неё нет внутреннего генератора, поэтому она полагается на микроконтроллер, который задаёт частоту.
  • Гибкие тона: вы можете управлять частотой и создавать разные тона, мелодии или сигналы тревоги, меняя входной сигнал.
пассивная пищалка
  • Как определить: обычно сверху нет покрытия, а внизу — что-то вроде платы (PCB) синего или зелёного цвета.

Какую выбрать?

Выбирайте активную пищалку, если:

  • вам нужен простой фиксированный тон или писк. Она идеально подходит для базовых оповещений, сигналов тревоги или подтверждения ввода пользователя.

Выбирайте пассивную пищалку, если:

  • вы хотите воспроизводить разные тона, мелодии или звуковые узоры.

Подключение пищалки к ESP32

У пищалки два вывода: положительный (сигнал) и земля (GND). Положительный вывод обычно отмечен знаком + и является более длинным, а отрицательный (земля) — более коротким, как и у светодиода. Однако у некоторых пассивных пищалок допускается подключать любой из выводов к земле или к сигналу, в зависимости от конкретной модели.

Кстати, в своём эксперименте я использовал активную пищалку. Если вы планируете воспроизводить разные звуки, рекомендую пассивную: она даёт лучший тон.

Вывод ESP32 Провод Вывод пищалки Примечания
GPIO 33
Положительный вывод Получает ШИМ-сигналы для воспроизведения звука.
GND
Вывод земли Подключается к земле.
подключение пищалки к esp32

Писк активной пищалкой на ESP32 с Rust

Поскольку вы уже знаете, что активная пищалка проста в использовании, достаточно подать на неё питание, и она запищит. В этом упражнении мы заставим её пищать, написав совсем немного кода.

Аппаратные требования

  • Активная пищалка
  • Провода-перемычки «female-to-male» или «male-to-male» (в зависимости от вашей схемы)

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

Этот шаг вы уже выполнили в разделе «Быстрый старт».

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 active-buzzer

Откроется экран с вопросом о выборе опций. Сейчас ничего выбирать не нужно. Просто сохраните настройки, нажав на клавиатуре «s».

Код

Мы настроим GPIO 33 как выходной вывод с начальным состоянием Low. К этому выводу подключён положительный вывод пищалки.

#![allow(unused)]
fn main() {
let mut buzzer = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
}

Логика простая: в цикле переводим вывод в High на 500 миллисекунд, затем в Low ещё на 500 миллисекунд. Так пищалка издаёт прерывистый писк.

#![allow(unused)]
fn main() {
   loop {
        buzzer.set_high();
        blocking_delay(Duration::from_millis(500));
        buzzer.set_low();
        blocking_delay(Duration::from_millis(500));
    }
}

Вспомогательная функция задержки

Она ждёт, пока не пройдёт заданное время.

#![allow(unused)]
fn main() {
fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку active-buzzer.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/active-buzzer

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::main;
use esp_hal::time::{Duration, Instant};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut buzzer = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());

    loop {
        buzzer.set_high();
        blocking_delay(Duration::from_millis(500));
        buzzer.set_low();
        blocking_delay(Duration::from_millis(500));
    }
}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Воспроизведение песен на пассивной пищалке на Rust и ESP32

Мы будем проигрывать песни на пищалке. Если вы не уверены в нотах и нотной записи, можете посмотреть краткую теорию здесь.

Я разбил код на модули Rust (в этом упражнении, как и раньше, можно обойтись одним файлом): music и got.

Для этого упражнения рекомендуется пассивная пищалка, хотя подойдёт любая.

ШИМ

Мы будем использовать ШИМ, чтобы менять частоту сигнала, подаваемого на пищалку, причём каждая частота соответствует определённой музыкальной ноте. Частота (нота) звучит заданное время, после чего переключается на следующую ноту в соответствии с нотным листом.

Например, нота «ля» (A4) имеет частоту 440 Гц и звучит некоторое время X. Мы задаём эту частоту в ШИМ и добавляем задержку на время X.

Рекомендую прочитать раздел о ШИМ, чтобы разобраться, как работает ШИМ.

Репозиторий с песнями

В этом упражнении мы будем проигрывать тему «Розовой пантеры» (Pink Panther). Однако вы можете посмотреть созданный мной репозиторий rust-embedded-songs и использовать другие песни.

Подмодули в lib.rs

Обновите lib.rs, чтобы объявить подмодули, а затем создайте файлы music.rs и pink_panther.rs.

#![allow(unused)]
#![no_std]
fn main() {
pub mod music;
pub mod pink_panther;
}

Введение в ноты и нотную запись

Это краткое руководство по нотам и нотной записи. Оно не охватывает всё, но даёт быструю справку по ключевым понятиям.

Нотный лист

Ноты для мелодии записаны по следующему нотному листу. Подробнее можно посмотреть по ссылке на MuseScore.

нотная запись

В музыке длительность нот задаётся следующими типами, которые определяют, сколько времени звучит каждая нота:

  • Целая нота: самая длинная длительность, звучит 4 доли.
  • Половинная нота: звучит 2 доли.
  • Четвертная нота: звучит 1 долю.
  • Восьмая нота: звучит полдоли, или 1/8 длительности целой ноты.
  • Шестнадцатая нота: звучит четверть доли, или 1/16 длительности целой ноты.

Нота с точкой

Нота с точкой — это нота, рядом с которой стоит точка. Точка увеличивает длительность ноты на половину её исходного значения. Например:

  • Половинная нота с точкой: звучит 3 доли (2 + 1).
  • Четвертная нота с точкой: звучит 1,5 доли (1 + 0,5).

Темп и BPM (ударов в минуту)

Темп — это скорость, с которой исполняется музыкальное произведение. Обычно он измеряется в ударах в минуту (BPM, beats per minute), то есть показывает, сколько долей укладывается в одну минуту.

Модуль music (music.rs)

В модуле music мы определяем константы для распространённых нот и соответствующие им значения частот.

#![allow(unused)]
fn main() {
// Частоты нот в герцах (f64)
pub const NOTE_B0: f64 = 31.0;
pub const NOTE_C1: f64 = 33.0;
pub const NOTE_CS1: f64 = 35.0;
pub const NOTE_D1: f64 = 37.0;
pub const NOTE_DS1: f64 = 39.0;
pub const NOTE_E1: f64 = 41.0;
pub const NOTE_F1: f64 = 44.0;
pub const NOTE_FS1: f64 = 46.0;
pub const NOTE_G1: f64 = 49.0;
pub const NOTE_GS1: f64 = 52.0;
pub const NOTE_A1: f64 = 55.0;
pub const NOTE_AS1: f64 = 58.0;
pub const NOTE_B1: f64 = 62.0;
pub const NOTE_C2: f64 = 65.0;
pub const NOTE_CS2: f64 = 69.0;
pub const NOTE_D2: f64 = 73.0;
pub const NOTE_DS2: f64 = 78.0;
pub const NOTE_E2: f64 = 82.0;
pub const NOTE_F2: f64 = 87.0;
pub const NOTE_FS2: f64 = 93.0;
pub const NOTE_G2: f64 = 98.0;
pub const NOTE_GS2: f64 = 104.0;
pub const NOTE_A2: f64 = 110.0;
pub const NOTE_AS2: f64 = 117.0;
pub const NOTE_B2: f64 = 123.0;
pub const NOTE_C3: f64 = 131.0;
pub const NOTE_CS3: f64 = 139.0;
pub const NOTE_D3: f64 = 147.0;
pub const NOTE_DS3: f64 = 156.0;
pub const NOTE_E3: f64 = 165.0;
pub const NOTE_F3: f64 = 175.0;
pub const NOTE_FS3: f64 = 185.0;
pub const NOTE_G3: f64 = 196.0;
pub const NOTE_GS3: f64 = 208.0;
pub const NOTE_A3: f64 = 220.0;
pub const NOTE_AS3: f64 = 233.0;
pub const NOTE_B3: f64 = 247.0;
pub const NOTE_C4: f64 = 262.0;
pub const NOTE_CS4: f64 = 277.0;
pub const NOTE_D4: f64 = 294.0;
pub const NOTE_DS4: f64 = 311.0;
pub const NOTE_E4: f64 = 330.0;
pub const NOTE_F4: f64 = 349.0;
pub const NOTE_FS4: f64 = 370.0;
pub const NOTE_G4: f64 = 392.0;
pub const NOTE_GS4: f64 = 415.0;
pub const NOTE_A4: f64 = 440.0;
pub const NOTE_AS4: f64 = 466.0;
pub const NOTE_B4: f64 = 494.0;
pub const NOTE_C5: f64 = 523.0;
pub const NOTE_CS5: f64 = 554.0;
pub const NOTE_D5: f64 = 587.0;
pub const NOTE_DS5: f64 = 622.0;
pub const NOTE_E5: f64 = 659.0;
pub const NOTE_F5: f64 = 698.0;
pub const NOTE_FS5: f64 = 740.0;
pub const NOTE_G5: f64 = 784.0;
pub const NOTE_GS5: f64 = 831.0;
pub const NOTE_A5: f64 = 880.0;
pub const NOTE_AS5: f64 = 932.0;
pub const NOTE_B5: f64 = 988.0;
pub const NOTE_C6: f64 = 1047.0;
pub const NOTE_CS6: f64 = 1109.0;
pub const NOTE_D6: f64 = 1175.0;
pub const NOTE_DS6: f64 = 1245.0;
pub const NOTE_E6: f64 = 1319.0;
pub const NOTE_F6: f64 = 1397.0;
pub const NOTE_FS6: f64 = 1480.0;
pub const NOTE_G6: f64 = 1568.0;
pub const NOTE_GS6: f64 = 1661.0;
pub const NOTE_A6: f64 = 1760.0;
pub const NOTE_AS6: f64 = 1865.0;
pub const NOTE_B6: f64 = 1976.0;
pub const NOTE_C7: f64 = 2093.0;
pub const NOTE_CS7: f64 = 2217.0;
pub const NOTE_D7: f64 = 2349.0;
pub const NOTE_DS7: f64 = 2489.0;
pub const NOTE_E7: f64 = 2637.0;
pub const NOTE_F7: f64 = 2794.0;
pub const NOTE_FS7: f64 = 2960.0;
pub const NOTE_G7: f64 = 3136.0;
pub const NOTE_GS7: f64 = 3322.0;
pub const NOTE_A7: f64 = 3520.0;
pub const NOTE_AS7: f64 = 3729.0;
pub const NOTE_B7: f64 = 3951.0;
pub const NOTE_C8: f64 = 4186.0;
pub const NOTE_CS8: f64 = 4435.0;
pub const NOTE_D8: f64 = 4699.0;
pub const NOTE_DS8: f64 = 4978.0;
pub const REST: f64 = 0.0; // Без звука, для пауз
}

Далее создаём небольшую вспомогательную struct, которая представляет музыкальную композицию (Song), и функции для расчёта длительности нот на основе темпа.

У этой структуры есть единственное поле whole_note, в котором хранится длительность целой ноты в миллисекундах. Мы храним длительность в миллисекундах, потому что музыкальное время часто задаётся темпом (ударов в минуту, BPM), и нам нужно вычислить, сколько времени длится каждая нота.

#![allow(unused)]
fn main() {

pub struct Song {
    whole_note: u32,
}
}

Формула (60_000 * 4) / tempo as u32 вычисляет длительность целой ноты в миллисекундах. Мы используем 60 000, потому что в минуте 60 000 миллисекунд, а умножаем на 4, так как целая нота обычно равна четырём долям.

#![allow(unused)]
fn main() {
impl Song {
    pub fn new(tempo: u16) -> Self {
        let whole_note = (60_000 * 4) / tempo as u32;
        Self { whole_note }
    }
}

calc_note_duration

Функция calc_note_duration вычисляет длительность музыкальной ноты относительно целой ноты. Она принимает параметр divider, который может быть положительным или отрицательным, и возвращает длительность ноты в миллисекундах.

#![allow(unused)]
fn main() {
    pub fn calc_note_duration(&self, divider: i16) -> u32 {
        if divider > 0 {
            self.whole_note / divider as u32
        } else {
            let duration = self.whole_note / divider.unsigned_abs() as u32;
            (duration as f64 * 1.5) as u32
        }
    }
}
}

Логика:

  1. Когда divider > 0:

    • Если divider положительный, функция вычисляет длительность ноты, деля длительность целой ноты на divider.
    • Например, если divider = 4, функция вычисляет длительность четвертной ноты, то есть 1/4 целой ноты.
  2. Когда divider <= 0:

    • Если divider отрицательный, функция сначала переводит его в положительное значение с помощью unsigned_abs().
    • Затем она делит длительность целой ноты на это абсолютное значение и умножает результат на 1,5, чтобы учесть ноты с точкой (например, четвертную ноту с точкой или восьмую с точкой), которые звучат в 1,5 раза дольше обычной ноты.

Такая логика с положительными и отрицательными значениями — собственный подход (я ориентировался на пример для Arduino), чтобы отличать ноты с точкой. Она не связана со стандартной музыкальной логикой.

Пример мелодии: тема Pink Panther (pink_panther.rs)

В этом разделе приведён код модуля pink_panther на Rust. Если нужны ноты других песен или фоновых мелодий, можете посмотреть репозиторий Rust Embedded Songs.

Темп

Здесь задаём темп песни (его можно изменить и посмотреть результат).

#![allow(unused)]
fn main() {
pub const TEMPO: u16 = 120;
}

Массив мелодии

Мелодию темы Pink Panther мы задаём массивом нот и их длительностей. Мелодия состоит из кортежей: частоты ноты и соответствующей длительности. Длительность каждой ноты представлена целым числом: положительные значения обозначают обычные ноты, а отрицательные — ноты с точкой.

#![allow(unused)]
fn main() {
pub const MELODY: [(f64, i16); 88] = [
    (REST, 2),
    (REST, 4),
    (REST, 8),
    (NOTE_DS4, 8),
    (NOTE_E4, -4),
    (REST, 8),
    (NOTE_FS4, 8),
    (NOTE_G4, -4),
    (REST, 8),
    (NOTE_DS4, 8),
    (NOTE_E4, -8),
    (NOTE_FS4, 8),
    (NOTE_G4, -8),
    (NOTE_C5, 8),
    (NOTE_B4, -8),
    (NOTE_E4, 8),
    (NOTE_G4, -8),
    (NOTE_B4, 8),
    (NOTE_AS4, 2),
    (NOTE_A4, -16),
    (NOTE_G4, -16),
    (NOTE_E4, -16),
    (NOTE_D4, -16),
    (NOTE_E4, 2),
    (REST, 4),
    (REST, 8),
    (NOTE_DS4, 4),
    (NOTE_E4, -4),
    (REST, 8),
    (NOTE_FS4, 8),
    (NOTE_G4, -4),
    (REST, 8),
    (NOTE_DS4, 8),
    (NOTE_E4, -8),
    (NOTE_FS4, 8),
    (NOTE_G4, -8),
    (NOTE_C5, 8),
    (NOTE_B4, -8),
    (NOTE_G4, 8),
    (NOTE_B4, -8),
    (NOTE_E5, 8),
    (NOTE_DS5, 1),
    (NOTE_D5, 2),
    (REST, 4),
    (REST, 8),
    (NOTE_DS4, 8),
    (NOTE_E4, -4),
    (REST, 8),
    (NOTE_FS4, 8),
    (NOTE_G4, -4),
    (REST, 8),
    (NOTE_DS4, 8),
    (NOTE_E4, -8),
    (NOTE_FS4, 8),
    (NOTE_G4, -8),
    (NOTE_C5, 8),
    (NOTE_B4, -8),
    (NOTE_E4, 8),
    (NOTE_G4, -8),
    (NOTE_B4, 8),
    (NOTE_AS4, 2),
    (NOTE_A4, -16),
    (NOTE_G4, -16),
    (NOTE_E4, -16),
    (NOTE_D4, -16),
    (NOTE_E4, -4),
    (REST, 4),
    (REST, 4),
    (NOTE_E5, -8),
    (NOTE_D5, 8),
    (NOTE_B4, -8),
    (NOTE_A4, 8),
    (NOTE_G4, -8),
    (NOTE_E4, -8),
    (NOTE_AS4, 16),
    (NOTE_A4, -8),
    (NOTE_AS4, 16),
    (NOTE_A4, -8),
    (NOTE_AS4, 16),
    (NOTE_A4, -8),
    (NOTE_AS4, 16),
    (NOTE_A4, -8),
    (NOTE_G4, -16),
    (NOTE_E4, -16),
    (NOTE_D4, -16),
    (NOTE_E4, 16),
    (NOTE_E4, 16),
    (NOTE_E4, 2),
];
}

Код

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

Этот шаг вы уже выполнили в разделе «Быстрый старт».

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 buzzer-song

Откроется экран с вопросом о выборе опций. В последних версиях esp-hal для ledc нужно явно включить нестабильные возможности.

  • Поэтому выберите опцию «Enable unstable HAL features» («Включить нестабильные возможности HAL»).

Затем сохраните настройки, нажав на клавиатуре «s».

Вывод пищалки

Мы зададим GPIO 33 как выходной вывод. К нему подключён положительный вывод пищалки.

#![allow(unused)]
fn main() {
let mut buzzer = peripherals.GPIO33;
let ledc = Ledc::new(peripherals.LEDC);
}

Экземпляр Song

Создаём экземпляр структуры Song с темпом песни, которую будем проигрывать.

#![allow(unused)]
fn main() {
let song = Song::new(pink_panther::TEMPO);
}

Воспроизведение нот с помощью ШИМ

Мы будем перебирать ноты песни, используя частоту и длительность каждой ноты. Также к длительности каждой ноты добавляем паузу в 10%. Константы частот определены как f64, мы преобразуем их в u64 и используем с Hz. Настройки таймера и канала ШИМ стандартные, с одним отличием: в таймере мы задаём частоту, соответствующую текущей ноте. Коэффициент заполнения задаём 50%.

#![allow(unused)]
fn main() {
 for (note, duration_type) in pink_panther::MELODY {
    // получаем длительность ноты
    let note_duration = song.calc_note_duration(duration_type) as u64;
    let pause_duration = note_duration / 10; // 10% от длительности ноты
    if note == music::REST {
        blocking_delay(Duration::from_millis(note_duration));
        continue;
    }

    // Инициализируем LEDC
    let freq = Rate::from_hz(note as u32);
    let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0);
    hstimer0
        .configure(timer::config::Config {
            duty: timer::config::Duty::Duty10Bit,
            clock_source: timer::HSClockSource::APBClk,
            frequency: freq,
        })
        .unwrap();

    // Инициализируем канал LEDC с коэффициентом заполнения 50%
    let mut channel0 = ledc.channel(channel::Number::Channel0, buzzer.reborrow());
    channel0
        .configure(channel::config::Config {
            timer: &hstimer0,
            duty_pct: 50,
            drive_mode: DriveMode::PushPull,
        })
        .unwrap();

    blocking_delay(Duration::from_millis(note_duration - pause_duration)); // играем 90%
    channel0.set_duty(0).unwrap();
    blocking_delay(Duration::from_millis(pause_duration)); // пауза 10%
}
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку buzzer-song.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/buzzer-song

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_hal::time::{Duration, Instant};

use buzzer_song::{
    music::{self, Song},
    pink_panther,
};

// LEDC
use esp_hal::gpio::DriveMode;
use esp_hal::ledc::HighSpeed;
use esp_hal::ledc::timer;
use esp_hal::{
    ledc::{
        Ledc,
        channel::{self, ChannelIFace},
        timer::TimerIFace,
    },
    time::Rate,
};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut buzzer = peripherals.GPIO33;

    let ledc = Ledc::new(peripherals.LEDC);

    let song = Song::new(pink_panther::TEMPO);

    for (note, duration_type) in pink_panther::MELODY {
        let note_duration = song.calc_note_duration(duration_type) as u64;
        let pause_duration = note_duration / 10; // 10% of note_duration
        if note == music::REST {
            blocking_delay(Duration::from_millis(note_duration));
            continue;
        }
        let freq = Rate::from_hz(note as u32);

        let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0);
        hstimer0
            .configure(timer::config::Config {
                duty: timer::config::Duty::Duty10Bit,
                clock_source: timer::HSClockSource::APBClk,
                frequency: freq,
            })
            .unwrap();

        let mut channel0 = ledc.channel(channel::Number::Channel0, buzzer.reborrow());
        channel0
            .configure(channel::config::Config {
                timer: &hstimer0,
                duty_pct: 50,
                drive_mode: DriveMode::PushPull,
            })
            .unwrap();

        blocking_delay(Duration::from_millis(note_duration - pause_duration)); // play 90%

        channel0.set_duty(0).unwrap();
        blocking_delay(Duration::from_millis(pause_duration)); // Pause for 10%
    }

    loop {
        blocking_delay(Duration::from_millis(5));
    }
}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Делитель напряжения

Делитель напряжения — это простая схема, которая уменьшает входное напряжение \( V_{in} \) до меньшего выходного напряжения \( V_{out} \) с помощью двух последовательно соединённых резисторов. Резистор, подключённый к входному напряжению \( V_{in} \), называется \( R_{1} \), а второй резистор — \( R_{2} \). Выходное напряжение \( V_{out} \) снимается с точки соединения \( R_{1} \) и \( R_{2} \) и составляет долю от \( V_{in} \).

Схема

Делитель напряжения

Выходное напряжение (Vout) рассчитывается по формуле:

\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]

Пример расчёта \( V_{out} \)

Дано:

  • \( V_{in} = 3.3V \)
  • \( R_1 = 10 k\Omega \)
  • \( R_2 = 10 k\Omega \)

Подставим значения:

\[ V_{out} = 3.3V \times \frac{10 k\Omega}{10 k\Omega + 10 k\Omega} = 3.3V \times \frac{10}{20} = 3.3V \times 0.5 = 1.65V \]

Выходное напряжение \( V_{out} \) равно 1,65 В.

fn main() {
    // Код можно редактировать
    // Можно менять значения и запускать программу
    let vin: f64 = 3.3;
    let r1: f64 = 10000.0;
    let r2: f64 = 10000.0;

    let vout = vin * (r2 / (r1 + r2));

    println!("The output voltage Vout is: {:.2} V", vout);
}

Применение

Делители напряжения используются в таких устройствах, как потенциометры: там сопротивление меняется при повороте ручки, регулируя выходное напряжение. Их также применяют для измерения резистивных датчиков, например датчиков света и термисторов. На такой делитель подаётся известное напряжение, а микроконтроллер считывает напряжение в средней точке, чтобы определить значение датчика, например температуру.

Симулятор делителя напряжения







Формула: Vout = Vin × (R2 / (R1 + R2))

Подставленная формула: Vout = 3.3 × (10000 / (10000 + 10000))

Выходное напряжение (Vout): 1.65 V

Симулятор на сайте Falstad

Для создания схемы я использовал сайт https://www.falstad.com/circuit/. Это отличный инструмент для рисования схем. Вы можете скачать созданный мной файл voltage-divider.circuitjs.txt и импортировать его, чтобы поэкспериментировать со схемой.

Ультразвуковой датчик HC-SR04

В этом руководстве мы научимся использовать ультразвуковой датчик HC-SR04 с ESP32. Ультразвуковые датчики измеряют расстояние, испуская ультразвуковые волны и вычисляя время, за которое они возвращаются после отражения от объекта.

Такие датчики часто встречаются в системах помощи при парковке: когда вы сдаёте назад, датчик измеряет расстояние до предметов и предупреждает, когда вы подъезжаете слишком близко.

Мы создадим простой проект, который постепенно увеличивает яркость светодиода с помощью ШИМ, когда ультразвуковой датчик обнаруживает объект на расстоянии меньше 30 см. Это значение можно изменить под свои задачи.

ультразвуковой датчик

Предварительные требования

Перед началом познакомьтесь с этими темами:

🛠 Аппаратные требования

Чтобы выполнить этот проект, вам понадобятся:

  • Ультразвуковой датчик HC-SR04 (или HC-SR04+)
  • Макетная плата
  • Провода-перемычки
  • Внешний светодиод (можно использовать и встроенный светодиод, тогда подключите GPIO 2)

Характеристики HC-SR04

Ультразвуковой датчик HC-SR04 может измерять расстояния от 2 до 400 см с точностью до 3 мм. Для работы ему нужно питание 5 В.

Допустимое напряжение ESP32

В следующей главе мы подробно разберём, как работает датчик, а пока базовая идея такая: датчик отправляет сигнал и ждёт, когда тот вернётся. При питании 5 В вывод Echo датчика выдаёт сигнал 5 В обратно подключённому устройству. Но тут есть проблема: выводы GPIO ESP32 выдерживают не более 3,6 В. Всё, что выше, может повредить микроконтроллер.

Как решить проблему? Есть несколько вариантов:

  • Самый простой — взять HC-SR04+, улучшенную версию, которая работает и с 3,3 В, и с 5 В. Тогда модуль можно питать от 3,3 В.
  • Другой вариант — использовать делитель напряжения (всего пара резисторов) между выводом Echo и ESP32, чтобы понизить напряжение до 3,3 В.
  • Последний вариант — питать HC-SR04 от 3,3 В. Это может работать, но не гарантирует надёжности. Избегайте этого варианта, если это не крайняя мера и вы готовы к возможным побочным эффектам.

Как работает ультразвуковой датчик?

Ультразвуковые датчики работают, испуская звуковые волны на частоте (40 кГц), слишком высокой для человеческого слуха. Эти волны распространяются в воздухе и отражаются, когда попадают на объект. Датчик вычисляет расстояние, измеряя, сколько времени звуковым волнам нужно, чтобы вернуться.

ультразвуковой датчик
  • Передатчик: испускает ультразвуковые звуковые волны.
  • Приёмник: регистрирует звуковые волны, которые отразились от объекта.

Формула для расчёта расстояния:

Distance = (Time x Speed of Sound) / 2

Скорость звука примерно равна 0,0343 см/мкс (или 343 м/с) при нормальном атмосферном давлении и температуре 20 °C.

Пример расчёта:

Допустим, ультразвуковой датчик зафиксировал, что звуковой волне понадобилось 2000 мкс, чтобы вернуться после попадания в объект.

Шаг 1: вычисляем общее расстояние, которое прошла звуковая волна:

Total distance = Time x Speed of Sound
Total distance = 2000 µs x 0343 cm/µs = 68.6 cm

Шаг 2: поскольку звуковая волна прошла до объекта и обратно, расстояние до объекта равно половине общего расстояния:

Distance to object = 68.6 cm / 2 = 34.3 cm

Таким образом, объект находится на расстоянии 34,3 см от датчика.

Распиновка HC-SR04

У модуля четыре вывода: VCC, Trig, Echo и GND.

Вывод Функция
VCC Питание
Trig Сигнал запуска (Trigger)
Echo Эхо-сигнал (Echo)
GND Земля

Измерение расстояния с помощью модуля HC-SR04

Модуль HC-SR04 содержит передатчик и приёмник, которые отвечают за отправку ультразвуковых волн и регистрацию отражённых волн. Мы будем использовать вывод Trig, чтобы отправлять звуковые волны, и читать вывод Echo, чтобы измерять расстояние.

ультразвуковой датчик

Как видно на схеме, мы подключаем выводы Trig и Echo к выводам GPIO микроконтроллера (VCC и GND тоже подключаются, но на схеме их не показали, чтобы не усложнять иллюстрацию). Чтобы отправить ультразвуковые волны, мы переводим вывод Trig в High на 10 микросекунд, а затем обратно в Low. Это заставляет модуль отправить 8 последовательных ультразвуковых импульсов на частоте 40 кГц. Рекомендуется делать паузу не менее 50 мс между запусками.

Когда волны датчика попадают в объект, они отражаются обратно к модулю. Как видно на схеме, вывод Echo меняет сигнал, который передаётся микроконтроллеру: длительность, в течение которой сигнал остаётся в высоком состоянии (ширина импульса), соответствует расстоянию. Микроконтроллер измеряет, сколько времени вывод Echo остаётся в High; затем по этому времени вычисляется расстояние до объекта.

Ширина импульса и расстояние:

Ширина импульса (время, в течение которого сигнал остаётся в высоком состоянии) на выводе Echo составляет примерно от 150 мкс до 25 000 мкс (25 мс), но только если сигнал попал в объект. Если объекта нет, ширина импульса будет около 38 мс.

Подключение HC-SR04 к ESP32

Зачем нужен делитель напряжения?

Этот раздел можно пропустить, если у вас HC-SR04+, который также принимает питание 3,3 В.

Прежде чем переходить к подключению, познакомьтесь с понятием делителя напряжения: см. эту главу. Как мы говорили ранее, модуль нужно питать от 5 В, которые можно подать через вывод Vin ESP32. Модуль возвращает сигнал через вывод Echo. Однако выводы GPIO ESP32 допускают не более 3,6 В, поэтому напряжение нужно понизить с помощью делителя между выводом Echo и выводом GPIO ESP32.

Для этого понадобятся два резистора с разными номиналами, чтобы выходное напряжение было примерно 3,3 В. Например, можно взять резистор 1 кОм в качестве R1 и резистор 2 кОм в качестве R2: они понизят напряжение примерно до 3,3 В.

делитель напряжения для HC-SR04

Если хотите поэкспериментировать с разными номиналами резисторов, можно использовать сайт Falstad и текстовый файл схемы делителя. Он позволяет менять значения R1 и R2 и смотреть, какое напряжение выдаёт каждая комбинация. Так вы сможете собрать делитель из резисторов, которые у вас есть.

Схема для HC-SR04

Вывод ESP32 Провод Вывод HC-SR04
Vin (5 В)
VCC
GPIO 5
Trig
GPIO 18
Echo (через делитель напряжения)
GND
GND
  • VCC: подключите вывод VCC датчика HC-SR04 к выводу Vin ESP32. Если используете HC-SR04+, подключите к выводу 3,3 В ESP32.
  • Trig: подключите к GPIO 5 ESP32.
  • Echo: подключите вывод Echo датчика HC-SR04 к GPIO 18 через делитель напряжения (резистор 1 кОм между Echo и GPIO 18 и резистор 2 кОм между GPIO 18 и GND; таким образом, GPIO 18 оказывается посередине). Думаю, с принципиальной схемой это будет понятнее.
  • GND: подключите вывод GND датчика HC-SR04 к выводу GND ESP32.

Схема для светодиода

Анод (длинную ножку) светодиода подключите к GPIO 33, как в разделе о внешнем светодиоде, через резистор (например, 330 Ом), чтобы не повредить светодиод. Катод (короткую ножку) светодиода подключите к земле.

Вывод ESP32 Провод Компонент
GPIO 33
Резистор
Резистор
Анод (длинная ножка) светодиода
GND
Катод (короткая ножка) светодиода

Схема подключения

Я привёл схемы как с макетной платой, так и без неё. Признаюсь, версия с макетной платой получилась у меня немного запутанной. Если всё ещё непонятно, создайте issue в репозитории GitHub и опишите, что неясно. Я постараюсь улучшить схему.

Схема без макетной платы:

подключение ESP32 к ультразвуковому датчику HC-SR04

Схема с макетной платой: подключение ESP32 к ультразвуковому датчику HC-SR04

Пишем код на Rust для ультразвукового датчика HC-SR04 с ESP32

Мы начнём с создания проекта по шаблону, а затем изменим код под требования текущего проекта.

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

Этот шаг вы уже выполнили в разделе «Быстрый старт».

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 ultrasonic

Откроется экран с вопросом о выборе опций. В последних версиях esp-hal для ledc нужно явно включить нестабильные возможности.

  • Поэтому выберите опцию «Enable unstable HAL features» («Включить нестабильные возможности HAL»).

Затем сохраните настройки, нажав на клавиатуре «s».

Настройка вывода светодиода и ШИМ

Этот код вы уже должны понимать. Если нет, сначала пройдите раздел о плавном затухании светодиода.

Краткое напоминание: здесь мы настраиваем ШИМ для светодиода, что позволяет управлять яркостью, меняя коэффициент заполнения.

#![allow(unused)]
fn main() {
// let led = peripherals.GPIO2; // uses onboard LED
let led = peripherals.GPIO33;

// Настраиваем LEDC
let mut ledc = Ledc::new(peripherals.LEDC);
ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);

let mut lstimer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0);
lstimer0
    .configure(timer::config::Config {
        duty: timer::config::Duty::Duty5Bit,
        clock_source: timer::LSClockSource::APBClk,
        frequency: Rate::from_khz(24),
    })
    .unwrap();

let mut channel0 = ledc.channel(channel::Number::Channel0, led);
channel0
    .configure(channel::config::Config {
        timer: &lstimer0,
        duty_pct: 10,
        drive_mode: DriveMode::PushPull,
    })
    .unwrap();
}

Настройка вывода Trig

Мы настроим GPIO 5 как выходной вывод с начальным состоянием LOW. Если интересно, почему он выходной: мы отправляем сигнал с ESP32 на ультразвуковой модуль. Этот вывод подключён к выводу Trig модуля.

#![allow(unused)]
fn main() {
let mut trig = Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default());
}

Настройка вывода Echo

Мы настроим GPIO 18 как входной вывод, так как ультразвуковой модуль возвращает сигнал на ESP32. Начальное состояние этого вывода задаём как Pull Down, чтобы он начинал работу в низком состоянии.

#![allow(unused)]
fn main() {
let echo = Input::new(
    peripherals.GPIO18,
    InputConfig::default().with_pull(Pull::Down),
);
}

🦇 Включаем

Шаг 1: отправляем импульс запуска

Сначала переводим вывод trig в LOW, чтобы начать с чистого состояния. Затем переводим вывод trig в HIGH на 10 микросекунд и снова в LOW. Это заставит модуль отправить ультразвуковые волны.

#![allow(unused)]
fn main() {
// Перед стартом убеждаемся, что вывод Trigger в низком состоянии
trig.set_low();
delay.delay_micros(2);

// Отправляем высокий импульс длительностью 10 микросекунд
trig.set_high();
delay.delay_micros(10);
trig.set_low();
}

Шаг 2: измеряем ширину импульса

Далее используем два цикла. Первый выполняется, пока вывод echo находится в LOW. Как только он переходит в HIGH, мы записываем текущее время в переменную. Затем запускаем второй цикл, который продолжается, пока вывод echo остаётся в HIGH. Когда он возвращается в LOW, записываем текущее время в другую переменную. Разность этих двух значений даёт ширину импульса.

#![allow(unused)]
fn main() {
// Измеряем, как долго сигнал остаётся в высоком состоянии
while echo.is_low() {}
let time1 = rtc.current_time_us();
while echo.is_high() {}
let time2 = rtc.current_time_us();
let pulse_width = time2 - time1;
}

Шаг 3: вычисляем расстояние

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

Скорость звука в воздухе примерно равна 0,0343 см на микросекунду. Умножив время (в микросекундах) на это значение и разделив на 2, получим расстояние до препятствия в сантиметрах.

#![allow(unused)]
fn main() {
let distance = (pulse_width * 0.0343) / 2.0;
}

Шаг 4: коэффициент заполнения ШИМ для светодиода

Наконец, регулируем яркость светодиода в зависимости от измеренного расстояния.

Коэффициент заполнения рассчитывается по нашей собственной логике, её можно изменить под свои задачи. Когда объект ближе 30 см, яркость светодиода растёт. Чем ближе объект к ультразвуковому модулю, тем выше получается рассчитанное отношение, а значит, тем сильнее меняется коэффициент заполнения. В результате яркость светодиода постепенно увеличивается по мере приближения объекта к датчику.

#![allow(unused)]
fn main() {
let duty_pct: u8 = if distance < 30.0 {
    let ratio = (30.0 - distance) / 30.0;
    let p = (ratio * 100.0) as u8;
    p.min(100)
} else {
    0
};

if let Err(e) = channel0.set_duty(duty_pct) {
    esp_println::println!("Failed to set duty cycle: {:?}", e);
}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::main;

// LEDC
use esp_hal::gpio::DriveMode;
use esp_hal::gpio::{InputConfig, OutputConfig};
use esp_hal::ledc::{LSGlobalClkSource, LowSpeed};
use esp_hal::time::Rate;

use esp_hal::{
    delay::Delay,
    gpio::{Input, Level, Output, Pull},
    ledc::{
        Ledc,
        channel::{self, ChannelIFace},
        timer::{self, TimerIFace},
    },
    rtc_cntl::Rtc,
};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    // let led = peripherals.GPIO2; // использует встроенный светодиод
    let led = peripherals.GPIO33;

    // Настраиваем LEDC
    let mut ledc = Ledc::new(peripherals.LEDC);
    ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);

    let mut lstimer0 = ledc.timer::<LowSpeed>(timer::Number::Timer0);
    lstimer0
        .configure(timer::config::Config {
            duty: timer::config::Duty::Duty5Bit,
            clock_source: timer::LSClockSource::APBClk,
            frequency: Rate::from_khz(24),
        })
        .unwrap();

    let mut channel0 = ledc.channel(channel::Number::Channel0, led);
    channel0
        .configure(channel::config::Config {
            timer: &lstimer0,
            duty_pct: 10,
            drive_mode: DriveMode::PushPull,
        })
        .unwrap();

    // Для ультразвукового датчика HC-SR04
    let mut trig = Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default());
    let echo = Input::new(
        peripherals.GPIO18,
        InputConfig::default().with_pull(Pull::Down),
    );

    let delay = Delay::new(); // Можно использовать, так как включены нестабильные возможности

    let rtc = Rtc::new(peripherals.LPWR);

    loop {
        delay.delay_millis(5);

        // Отправляем ультразвуковые волны
        trig.set_low();
        delay.delay_micros(2);
        trig.set_high();
        delay.delay_micros(10);
        trig.set_low();

        // Измеряем, как долго сигнал остаётся в высоком состоянии
        while echo.is_low() {}
        let time1 = rtc.current_time_us();
        while echo.is_high() {}
        let time2 = rtc.current_time_us();
        let pulse_width = time2 - time1;

        // Вычисляем расстояние по ширине импульса
        let distance = (pulse_width as f64 * 0.0343) / 2.0;
        // esp_println::println!("Pulse Width: {}", pulse_width);
        // esp_println::println!("Distance: {}", distance);

        // Наша собственная логика расчёта коэффициента заполнения по расстоянию
        let duty_pct: u8 = if distance < 30.0 {
            let ratio = (30.0 - distance) / 30.0;
            let p = (ratio * 100.0) as u8;
            p.min(100)
        } else {
            0
        };

        if let Err(e) = channel0.set_duty(duty_pct) {
            // esp_println::println!("Failed to set duty cycle: {:?}", e);
            panic!("Failed to set duty cycle: {:?}", e);
        }

        delay.delay_millis(60);
    }
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку ultrasonic.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/ultrasonic

Пищалка для обнаружения объектов: ESP32 и ультразвуковой датчик

В предыдущем упражнении мы использовали светодиод, который становился ярче по мере приближения объекта к ультразвуковому датчику. Теперь вместо светодиода мы используем активную пищалку. Она будет издавать звук по мере приближения объекта. Хотя с помощью ШИМ можно создавать разные звуки и тона, в этом проекте мы оставим всё простым: когда объект приближается к датчику, пищалка издаёт писк.

Схема

Схема почти такая же, как раньше. Единственное отличие: нужно убрать светодиод и его резистор. Вместо них подключите пищалку к GPIO 33. Положительный вывод пищалки (обычно отмеченный знаком «плюс») подключаем к GPIO 33, а другой вывод — к земле.

схема HC-SR04, пищалки и ESP32

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

Этот шаг вы уже выполнили в разделе «Быстрый старт».

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 ultrasonic-alert

Откроется экран с вопросом о выборе опций. Нестабильные или другие дополнительные функции нам не нужны, поэтому просто сохраните настройки, нажав на клавиатуре «s».

Код

Мы настроим GPIO 33 как выходной вывод с начальным состоянием Low. Это то же, что и в коде для светодиода, меняется только имя переменной.

#![allow(unused)]
fn main() {
let mut buzzer = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
}

Таймер и настройки ШИМ, которые мы использовали для светодиода, здесь не нужны. Вместо этого мы переводим пищалку в High (она издаёт звук в состоянии High), если расстояние меньше 30 см; в противном случае она остаётся в Low.

#![allow(unused)]
fn main() {
if distance < 30.0 {
    buzzer.set_high();
} else {
    buzzer.set_low();
}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
use esp_hal::main;
use esp_hal::time::{Duration, Instant};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut buzzer = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());

    // Для ультразвукового датчика HC-SR04
    let mut trig = Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default());
    let echo = Input::new(
        peripherals.GPIO18,
        InputConfig::default().with_pull(Pull::Down),
    );

    loop {
        blocking_delay(Duration::from_millis(5));

        // Отправляем ультразвуковые волны
        trig.set_low();
        blocking_delay(Duration::from_micros(2));
        trig.set_high();
        blocking_delay(Duration::from_micros(10));
        trig.set_low();

        // Измеряем, как долго сигнал остаётся в высоком состоянии
        while echo.is_low() {}
        let time1 = Instant::now();
        while echo.is_high() {}
        let pulse_width = time1.elapsed().as_micros();

        // Вычисляем расстояние по ширине импульса
        let distance = (pulse_width as f64 * 0.0343) / 2.0;
        // esp_println::println!("Pulse Width: {}", pulse_width);
        // esp_println::println!("Distance: {}", distance);

        if distance < 30.0 {
            buzzer.set_high();
        } else {
            buzzer.set_low();
        }

        blocking_delay(Duration::from_millis(60));
    }
}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку ultrasonic-alert.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/ultrasonic-alert

PIR-датчик

Вы когда-нибудь заходили в комнату, и свет включался сам? Скорее всего, за этим стоит PIR-датчик.

В одной из предыдущих глав мы использовали ультразвуковой датчик, чтобы проверять, находится ли рядом человек или объект. Он работает, посылая ультразвуковые волны и измеряя расстояние до объекта. PIR-датчик работает иначе: вместо измерения расстояния он обнаруживает движение.

PIR-датчик называют «пассивным» инфракрасным (passive Infrared), потому что он сам не излучает инфракрасное излучение, а лишь регистрирует изменения инфракрасного излучения в окружающей среде. Он чувствует тепло, которое излучают люди, животные и другие тёплые объекты. Когда движение происходит в зоне обнаружения, датчик фиксирует изменение и посылает сигнал. Поэтому он полезен для автоматического освещения, охранных сигнализаций и других систем обнаружения движения.

Знакомство с железом

Мы будем использовать PIR-модуль HC-SR501. На нём есть встроенный пироэлектрический датчик, который помогает обнаруживать движение, и куполообразная линза Френеля, которая увеличивает дальность обнаружения. Он может работать от питания 5–12 В.

У модуля PIR-датчика три вывода: питание, выход (средний вывод) и земля. Использовать его просто, потому что он выдаёт простой выходной сигнал. По умолчанию сигнал остаётся в LOW, пока движение не обнаружено. Но как только кто-то движется в зоне действия датчика, выход переходит в HIGH, сигнализируя об обнаружении движения.

HC-SR501

Ресурсы

Модуль PIR-датчика построен на базе контроллера BISS0001. Ниже приведены несколько даташитов, связанных с контроллером BISS0001.

Если хотите узнать, как PIR-датчики устроены внутри, посмотрите это руководство: How PIRs Work. В книге мы этого касаться не будем, так как это выходит за её рамки. Наша задача проще: обнаружить движение и передать сигнал в ESP32.

Настройка PIR-датчика

HC-SR501

Изображение: Adafruit

Модуль PIR-датчика имеет два встроенных подстроечных резистора, которые позволяют тонко настроить его работу: время задержки (Delay Time) и чувствительность (Sensitivity). Эти маленькие регулировочные ручки позволяют управлять тем, как датчик реагирует на движение, и делают его более адаптивным к разным условиям и задачам.

Время задержки (длительность выходного сигнала)

Эта настройка определяет, как долго выход датчика остаётся в HIGH после обнаружения движения. Большая задержка полезна в таких задачах, как автоматическое освещение, когда свет нужно оставить включённым какое-то время после движения.

  • Поверните по часовой стрелке, чтобы увеличить длительность (до ~200 секунд).
  • Поверните против часовой стрелки, чтобы уменьшить длительность (до ~5 секунд).

Чувствительность (дальность обнаружения)

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

  • Поверните по часовой стрелке, чтобы увеличить дальность (до ~7 метров).
  • Поверните против часовой стрелки, чтобы уменьшить дальность (до ~3 метров).

Примечание:

Честно говоря, код, который мы сейчас напишем, очень простой. Самым сложным для меня было разобраться с направлением. Я знал, что нужно крутить по часовой и против часовой стрелки, но не был уверен, должен ли датчик смотреть вверх или вниз. Ключевой момент: держите датчик куполом вверх и затем следуйте инструкциям по часовой и против часовой стрелки.

Настройка перемычками

Помимо ручек, есть и перемычки. Они позволяют переключаться между двумя режимами: с повторным запуском (Retriggering) и без повторного запуска (Non-Retriggering).

HC-SR501

Режим с повторным запуском (H)

В этом режиме выход остаётся в HIGH, пока обнаруживается движение. Если движение происходит до окончания таймера, он сбрасывается. Этот режим идеален для ситуаций, когда свет включается, как только кто-то заходит в комнату, и остаётся включённым, пока есть движение.

Режим без повторного запуска (L)

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

Как установить перемычку

Чтобы переключить режим, переставьте перемычку (небольшой чёрно-жёлтый элемент; да, её можно снять и поставить между двумя контактами) на контакты H для режима с повторным запуском или на контакты L для режима без повторного запуска.

Распиновка HC-SR501

Выход легко найти: он находится прямо посередине.

Если вы не уверены, какой вывод — земля (GND), а какой — VCC, можно снять белый купол и проверить. Также VCC можно определить по выводу рядом с защитным диодом.

Подключение PIR-датчика к ESP32

Датчик работает от 5 В, но выводы GPIO ESP32 допускают только 3,3 В. Модуль PIR-датчика выдаёт 3,3 В при обнаружении движения, поэтому его можно напрямую подключить к GPIO без проблем. В противном случае понадобится делитель напряжения, чтобы снизить напряжение до 3,3 В.

Вывод ESP32 Провод Вывод PIR-датчика
GPIO 33
OUT (средний вывод)
5V
VCC
GND
GND

HC-SR501

Пишем код на Rust для обнаружения движения с PIR-датчиком и ESP32

Давайте напишем простую программу, которая выводит сообщение каждый раз, когда обнаружено движение. Это поможет точнее настроить PIR-датчик и разобраться в базовых понятиях. Затем мы соберём полноценную имитацию охранной сигнализации с пищалкой и светодиодом (встроенным или внешним, на ваш выбор), чтобы сделать её интереснее.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 pir-sensor

Откроется экран с вопросом о выборе опций.

В этом упражнении мы будем выводить сообщения в системную консоль. Для этого нужно включить функцию логирования.

Прокрутите список до пункта «Flashing, logging and debugging (espflash)» и нажмите Enter.

Выберите «Use defmt to print messages» («Использовать defmt для вывода сообщений»).

Сохраните настройки, нажав на клавиатуре «s».

Подключение выхода датчика ко входу ESP32

Мы настроим GPIO 33 как входной вывод с начальным состоянием pull-down. Этот вывод подключён к выходу PIR-датчика, который переходит в HIGH каждый раз, когда обнаружено движение.

#![allow(unused)]
fn main() {
let sensor_pin = Input::new(
    peripherals.GPIO33,
    InputConfig::default().with_pull(Pull::Down),
);
}

Логика

Идея простая: мы постоянно проверяем выход датчика в цикле. Когда выход датчика переходит в HIGH, мы выводим сообщение «Motion detected» и делаем короткую задержку.

#![allow(unused)]
fn main() {
loop {
    if sensor_pin.is_high() {
        info!("Motion detected");
        blocking_delay(Duration::from_millis(100));
    }
    blocking_delay(Duration::from_millis(100));
}
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку pir-sensor.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/pir-sensor

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Input, InputConfig, Pull};
use esp_hal::main;
use esp_hal::time::{Duration, Instant};
use esp_println as _;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let sensor_pin = Input::new(
        peripherals.GPIO33,
        InputConfig::default().with_pull(Pull::Down),
    );

    loop {
        if sensor_pin.is_high() {
            info!("Motion detected");
            blocking_delay(Duration::from_millis(100));
        }
        blocking_delay(Duration::from_millis(100));
    }
}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Простая охранная сигнализация на ESP32, PIR-датчике и Rust

Сделаем простую охранную сигнализацию: пищалка включается ненадолго, а затем выключается. Мы также включаем встроенный светодиод, подключённый к GPIO2. Смело меняйте это под свои задачи!

Аппаратные требования

  • Активная пищалка
  • PIR-датчик
  • Провода-перемычки

Схема

Подключение PIR-датчика такое же, как раньше (см. схему). Средний выходной вывод датчика подключаем к GPIO 33.

Подключение вывода пищалки:

Вывод ESP32 Провод Вывод пищалки
GPIO 18
Положительный вывод
GND
Отрицательный вывод
HC-SR501

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 burglar-alarm

Выводы пищалки и светодиода

Мы настроим GPIO 18 как выходной вывод с начальным состоянием Low для активной пищалки. Встроенный светодиод, подключённый к GPIO 2, тоже настраивается как выходной вывод с начальным состоянием Low.

#![allow(unused)]
fn main() {
let mut buzzer_pin = Output::new(peripherals.GPIO18, Level::Low, OutputConfig::default());
let mut led = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
}

Логика

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

#![allow(unused)]
fn main() {
loop {
    if sensor_pin.is_high() {
        buzzer_pin.set_high();
        led.set_high();
        blocking_delay(Duration::from_millis(100));
        buzzer_pin.set_low();
        led.set_low();
    }
    blocking_delay(Duration::from_millis(100));
}
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку burglar-alarm.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/burglar-alarm

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
use esp_hal::main;
use esp_hal::time::{Duration, Instant};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let sensor_pin = Input::new(
        peripherals.GPIO33,
        InputConfig::default().with_pull(Pull::Down),
    );

    let mut buzzer_pin = Output::new(peripherals.GPIO18, Level::Low, OutputConfig::default());
    let mut led = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());

    loop {
        if sensor_pin.is_high() {
            buzzer_pin.set_high();
            led.set_high();
            blocking_delay(Duration::from_millis(100));
            buzzer_pin.set_low();
            led.set_low();
        }
        blocking_delay(Duration::from_millis(100));
    }
}

fn blocking_delay(duration: Duration) {
    let delay_start = Instant::now();
    while delay_start.elapsed() < duration {}
}

Сервопривод

В этом разделе мы подключим микросервопривод SG90 к ESP32 и будем управлять его поворотом с помощью ШИМ. Перед тем как двигаться дальше, убедитесь, что вы прочитали введение в ШИМ.

Аппаратные требования

  • Микросервопривод SG90: недорогой сервопривод, созданный для любителей и хобби-проектов.
  • Провода-перемычки: провода «male-to-male» (или «female-to-male», в зависимости от подключения) для соединения ESP32 с выводами сервопривода (земля, питание и сигнал).

Введение в сервоприводы

Сервоприводы широко используются в робототехнике (например, в руках роботов), радиоуправляемых машинах, поворотных креплениях для камер и других встраиваемых системах. Один из самых популярных сервоприводов — SG90, небольшой и лёгкий сервопривод, который часто применяют в самодельных и хобби-проектах. SG90 поворачивается на угол до 180°.

Сервопривод SG90

Как работают сервоприводы (SG90)?

Обычно сервопривод содержит коллекторный двигатель постоянного тока, схему управления, редуктор и потенциометр. Двигатель соединён с выходным валом через редуктор, который поворачивает качалку (horn) сервопривода. Мы не будем разбирать, как внутри работают двигатель и редуктор: это выходит за рамки книги, а если интересно, можно изучить это самостоятельно. Нам важно лишь, как повернуть качалку с помощью микроконтроллера.

Чтобы управлять положением качалки, микроконтроллер (MCU) посылает сервоприводу сигнал с частотой 50 Гц, то есть импульс каждые 20 миллисекунд. Меняя длительность высокого уровня в каждом цикле (ширину импульса), мы управляем углом поворота качалки.

Для большинства сервоприводов (по данным даташитов из разных источников):

  • Импульс 1 мс поворачивает качалку на 0 градусов.
  • Импульс 1,5 мс поворачивает её на 90 градусов (нейтральное положение).
  • Импульс 2 мс поворачивает её на 180 градусов.

Тонкая настройка сервопривода

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

  • примерно 0,5 мс для 0 градусов;
  • примерно 1,5 мс для 90 градусов;
  • примерно 2,5 мс для 180 градусов.

Note

Ширина импульса и положение качалки сервопривода

Сервопривод удерживает своё положение, пока мы не изменим ширину импульса. Например, если продолжать посылать импульс 1,5 мс, он будет оставаться в положении 90 градусов. Когда мы изменим ширину импульса на 2,0 мс, он повернётся на 180 градусов. Однако нужно дать ему достаточно времени, чтобы дойти до нужного положения.

Также, если отправить неправильную ширину импульса (например, 10 мс), сервопривод вообще не будет двигаться.

Мне пришлось тестировать и подбирать длительности импульсов, чтобы найти правильные положения. Если вы не уверены, как ведёт себя ваш сервопривод, можно использовать такие инструменты, как осциллограф, для точной настройки ширины импульсов, или экспериментировать с разными значениями, чтобы найти наилучший вариант.

Пример в этой книге использует конфигурацию моего сервопривода. Возможно, значения придётся изменить в зависимости от конкретного сервопривода, с которым вы работаете.

Изменение положения сервопривода по коэффициенту заполнения ШИМ

Сервопривод, который мы используем, работает на частоте 50 Гц, то есть импульс отправляется каждые 20 миллисекунд (мс).

Разберём подробнее:

  • Частота 50 Гц: частота показывает, сколько раз событие происходит за единицу времени. Частота 50 Гц означает, что сервопривод ожидает импульс 50 раз в секунду. Другими словами, он получает импульс каждую пятидесятую долю секунды, то есть каждые 20 миллисекунд.
  • Интервал 20 мс: это время между импульсами. Каждые 20 миллисекунд сервопривод ожидает новый импульс, чтобы изменить своё положение. В пределах этих 20 мс ширина импульса (то, сколько времени сигнал остаётся «высоким») определяет угол, на который повернётся сервопривод.

Итак, когда мы говорим, что сервопривод работает на 50 Гц, это значит, что он постоянно получает импульсы каждые 20 мс, чтобы оставаться в движении или корректировать положение в зависимости от ширины каждого импульса.

Ширина импульса и коэффициент заполнения

Разберём подробнее, как разные ширины импульсов, например 0,5 мс, 1,5 мс и 2,5 мс, влияют на положение сервопривода.

pico2

Импульс 0,5 мс (положение: 0 градусов)

  • Что происходит: импульс 0,5 мс означает, что сигнал находится в «высоком» состоянии 0,5 миллисекунды в каждом 20-миллисекундном цикле. Сервопривод интерпретирует это как команду перейти в положение 0 градусов.

  • Коэффициент заполнения: коэффициент заполнения (duty cycle) — это процент времени, в течение которого сигнал находится в «высоком» состоянии за один полный цикл. Для импульса 0,5 мс: \[ \text{Коэффициент заполнения (%)} = \frac{0.5 \text{ms}}{20 \text{ms}} \times 100 = 2.5\% \]

    Это значит, что лишь 2,5% каждого 20-миллисекундного цикла сигнал находится в «высоком» состоянии, что заставляет сервопривод повернуться в положение 0 градусов.

Импульс 1,5 мс (положение: 90 градусов)

  • Что происходит: импульс 1,5 мс означает, что сигнал находится в «высоком» состоянии 1,5 миллисекунды в 20-миллисекундном цикле. Сервопривод переходит в нейтральное положение, примерно 90 градусов (среднее положение).
  • Коэффициент заполнения: для импульса 1,5 мс: \[ \text{Коэффициент заполнения (%)} = \frac{1.5 \text{ms}}{20 \text{ms}} \times 100 = 7.5\% \] Здесь сигнал находится в «высоком» состоянии 7,5% цикла, что устанавливает сервопривод в положение 90 градусов (нейтральное).

Импульс 2,5 мс (положение: 180 градусов)

  • Что происходит: импульс 2,5 мс означает, что сигнал находится в «высоком» состоянии 2,5 миллисекунды в 20-миллисекундном цикле. Сервопривод переходит в максимальное положение, обычно 180 градусов (полный поворот в одну сторону).
  • Коэффициент заполнения: для импульса 2,5 мс: \[ \text{Коэффициент заполнения (%)} = \frac{2.5 \text{ms}}{20 \text{ms}} \times 100 = 12.5\% \] В этом случае сигнал находится в «высоком» состоянии 12,5% цикла, что заставляет сервопривод повернуться на 180 градусов.

Общая схема подключения

  1. Земля (GND): подключите вывод GND сервопривода (обычно коричневый провод, хотя это может меняться) к любому выводу земли ESP32.
  2. Питание (VCC): подключите вывод VCC сервопривода (обычно красный провод) к выводу питания ESP32 5 В (Vin).
  3. Сигнал (PWM): подключите управляющий (сигнальный) вывод сервопривода к GPIO 33 ESP32, настроенному для ШИМ. Обычно это оранжевый провод (может отличаться).
Вывод ESP32 Провод Сервопривод Примечания
VIN
Питание (красный провод) Подаёт питание 5 В на сервопривод.
GND
Земля (коричневый провод) Подключается к земле.
GPIO 33
Сигнал (оранжевый провод) Получает ШИМ-сигнал для управления положением сервопривода.
подключение сервопривода к esp32

Периферия LEDC ESP32 для управления сервоприводом

ESP32 имеет периферию LEDC и Motor Control Pulse Width Modulator (MCPWM) для управления ШИМ. Сначала мы используем LEDC для генерации ШИМ, которую уже применяли в примерах со светодиодом, пищалкой и другими.

В предыдущих упражнениях мы использовали функцию set_duty, которая принимает процент коэффициента заполнения как u8. Но для сервопривода нужны дробные проценты (например, 2,5%, 7,5%, 12,5%), которые нельзя представить в виде u8.

Трейт SetDutyCycle

Что же делать? К счастью, каналы LEDC в esp-hal реализуют трейт SetDutyCycle из embedded-hal, который даёт больше контроля над ШИМ.

Мы будем использовать две функции трейта SetDutyCycle: max_duty_cycle и set_duty_cycle.

max_duty_cycle:

Эта функция возвращает максимальный коэффициент заполнения в зависимости от разрешения в битах, которое мы задаём для таймера. Например, если установить разрешение 8 бит, максимальный коэффициент заполнения будет 256 (то есть 28). Если установить 12 бит, максимум будет 4096 (то есть 212). Мы будем использовать 12-битное разрешение, поэтому максимальный коэффициент заполнения будет 4096.

#![allow(unused)]
fn main() {
// Приводим к u32 (из u16), потому что для последующего умножения нужен u32.
let max_duty_cycle = channel0.max_duty_cycle() as u32;
}

set_duty_cycle:

Эта функция задаёт коэффициент заполнения в диапазоне от 0 до 4096 при 12-битном разрешении.

#![allow(unused)]
fn main() {
let duty = 512;
channel0.set_duty_cycle(duty).unwrap();
}

Но как?

Эти функции принимают или возвращают значения u16. Но как использовать проценты, которые являются дробными? Вместо того чтобы использовать процент напрямую, мы вычисляем соответствующее значение. Например, 2,5% от 4096 примерно равны 102. Этого достаточно, чтобы повернуть сервопривод в нужное положение; в данном случае — на 0 градусов.

Для вычисления значения по проценту мы не будем использовать float. Вместо этого приведём максимальное значение коэффициента заполнения к u32.

Проценты вроде 2,5% нельзя напрямую представить в u32, поэтому умножаем процент на 10, чтобы он вписался. Например, 2,5 становится 25. Затем делим итоговое значение на 1000 (100 × 10), а не на 100.

\[ \text{min_duty} = \frac{Percent_{u32} \times \text{max_duty_cycle}}{1000} \]

Например:

#![allow(unused)]
fn main() {
// Минимальный коэффициент заполнения (2,5%) для положения сервопривода
// Для 12 бит -> 25 * 4096 /1000 => ~ 102
// то же самое, что 2.5 * 4096 / 100 => ~102
let min_duty = (25 * max_duty_cycle) / 1000;

// Максимальный коэффициент заполнения (12,5%) для положения сервопривода
// Для 12 бит -> 125 * 4096 /1000 => 512
let max_duty = (125 * max_duty_cycle) / 1000;

}

Вычисление коэффициента заполнения по углу

У нас есть простая вспомогательная функция, которая переводит угол в значение коэффициента заполнения. Нужно передать градусы, min_duty и разницу между min_duty и max_duty для диапазона положений сервопривода. Затем приводим результат к u16, потому что функция set_duty_cycle принимает только u16.

#![allow(unused)]
fn main() {
let duty_gap = max_duty - min_duty; // 512 - 102 => 410
fn duty_from_angle(deg: u32, min_duty: u32, duty_gap: u32) -> u16 {
    let duty = min_duty + ((deg * duty_gap) / 180);
    duty as u16
}
}

Например, возьмём угол 180 градусов. Мы уже вычислили диапазон min_duty и max_duty, то есть 102 и 512, а разница между ними 410. Подставим в формулу.

duty = 102 + ((180 * 410) / 180) = 512

Для угла 90 градусов:

duty = 102 + ((90 * 410) / 180) = 307

307 примерно равно 7,5% от 4096, что нам и нужно для положения 90 градусов.

Поворот

Будем поворачивать качалку сервопривода с 0 градусов на 180, а затем обратно на 0 в цикле. Сначала вычисляем коэффициент заполнения по углу и задаём его. Ждём 1500 миллисекунд, чтобы сервопривод успел дойти до положения. Попробуйте уменьшить задержку до 50 мс, и вы заметите, что сервопривод начнёт двигаться рывками и вообще не дойдёт до нужного положения.

#![allow(unused)]
fn main() {
loop{
    let duty = duty_from_angle(0, min_duty, duty_gap);
    channel0.set_duty_cycle(duty).unwrap();

    delay.delay_millis(1500); // даём время дойти до положения

    let duty = duty_from_angle(180, min_duty, duty_gap);
    channel0.set_duty_cycle(duty).unwrap();

    delay.delay_millis(1500); // даём время дойти до положения
}
}

Не переживайте, как запустить этот код. В следующей главе мы рассмотрим код, который плавно перемещает сервопривод от 0 до 180 градусов и обратно в цикле.

Пишем код на Rust для плавного поворота сервопривода с ESP32

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 servo-motor

Откроется экран с вопросом о выборе опций. В последних версиях esp-hal для ledc нужно явно включить нестабильные возможности.

  • Поэтому выберите опцию «Enable unstable HAL features» («Включить нестабильные возможности HAL»).

Затем сохраните настройки, нажав на клавиатуре «s».

Обновление зависимостей

Добавьте в файл Cargo.toml следующий крейт:

embedded-hal = "1.0.0"

Затем импортируйте трейт SetDutyCycle в файле main.rs. Он нужен, если мы хотим использовать функции max_duty_cycle и set_duty_cycle:

#![allow(unused)]
fn main() {
use embedded_hal::pwm::SetDutyCycle;
}

Таймер и канал ШИМ

Инициализируем таймер с частотой 50 Гц и 12-битным разрешением и настроим канал.

#![allow(unused)]
fn main() {
let mut servo = peripherals.GPIO33;
let ledc = Ledc::new(peripherals.LEDC);

let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0);
hstimer0
    .configure(timer::config::Config {
        duty: timer::config::Duty::Duty12Bit,
        clock_source: timer::HSClockSource::APBClk,
        frequency: Rate::from_hz(50),
    })
    .unwrap();

let mut channel0 = ledc.channel(channel::Number::Channel0, servo.reborrow());
channel0
    .configure(channel::config::Config {
        timer: &hstimer0,
        duty_pct: 10,
        drive_mode: DriveMode::PushPull,
    })
    .unwrap();
}

Вспомогательная функция

Код из предыдущей главы мы уже разобрали.

#![allow(unused)]
fn main() {
    let max_duty_cycle = channel0.max_duty_cycle() as u32;

    // Минимальный коэффициент заполнения (2,5%)
    // Для 12 бит -> 25 * 4096 /1000 => ~ 102
    let min_duty = (25 * max_duty_cycle) / 1000;
    // Максимальный коэффициент заполнения (12,5%)
    // Для 12 бит -> 125 * 4096 /1000 => 512
    let max_duty = (125 * max_duty_cycle) / 1000;
    // 512 - 102 => 410
    let duty_gap = max_duty - min_duty;
    
    fn duty_from_angle(deg: u32, min_duty: u32, duty_gap: u32) -> u16 {
        let duty = min_duty + ((deg * duty_gap) / 180);
        duty as u16
    }
}

Поворот

В главном цикле мы сначала поворачиваем сервопривод с 0 до 180 градусов. Между шагами делаем паузу 10 мс: сервопривод успевает дойти до положения, потому что движемся маленькими шагами. Затем с помощью rev() меняем диапазон на обратный, чтобы вернуться от 180 к 0.

#![allow(unused)]
fn main() {
loop {
    for deg in 0..=180 {
        let duty = duty_from_angle(deg, min_duty, duty_gap);
        channel0.set_duty_cycle(duty).unwrap();
        delay.delay_millis(10);
    }
    delay.delay_millis(500);

    for deg in (0..=180).rev() {
        let duty = duty_from_angle(deg, min_duty, duty_gap);
        channel0.set_duty_cycle(duty).unwrap();
        delay.delay_millis(10);
    }
    delay.delay_millis(500);
}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::delay::Delay;
use esp_hal::main;

// LEDC
use esp_hal::gpio::DriveMode;
use esp_hal::ledc::channel::ChannelIFace;
use esp_hal::ledc::timer::TimerIFace;
use esp_hal::ledc::{HighSpeed, Ledc, channel, timer};
use esp_hal::time::Rate;

use embedded_hal::pwm::SetDutyCycle;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut servo = peripherals.GPIO33;
    let ledc = Ledc::new(peripherals.LEDC);

    let mut hstimer0 = ledc.timer::<HighSpeed>(timer::Number::Timer0);
    hstimer0
        .configure(timer::config::Config {
            duty: timer::config::Duty::Duty12Bit,
            clock_source: timer::HSClockSource::APBClk,
            frequency: Rate::from_hz(50),
        })
        .unwrap();

    let mut channel0 = ledc.channel(channel::Number::Channel0, servo.reborrow());
    channel0
        .configure(channel::config::Config {
            timer: &hstimer0,
            duty_pct: 10,
            drive_mode: DriveMode::PushPull,
        })
        .unwrap();

    let delay = Delay::new();

    let max_duty_cycle = channel0.max_duty_cycle() as u32;

    // Минимальный коэффициент заполнения (2,5%)
    // Для 12 бит -> 25 * 4096 /1000 => ~ 102
    let min_duty = (25 * max_duty_cycle) / 1000;
    // Максимальный коэффициент заполнения (12,5%)
    // Для 12 бит -> 125 * 4096 /1000 => 512
    let max_duty = (125 * max_duty_cycle) / 1000;
    // 512 - 102 => 410
    let duty_gap = max_duty - min_duty;

    loop {
        for deg in 0..=180 {
            let duty = duty_from_angle(deg, min_duty, duty_gap);
            channel0.set_duty_cycle(duty).unwrap();
            delay.delay_millis(10);
        }
        delay.delay_millis(500);

        for deg in (0..=180).rev() {
            let duty = duty_from_angle(deg, min_duty, duty_gap);
            channel0.set_duty_cycle(duty).unwrap();
            delay.delay_millis(10);
        }
        delay.delay_millis(500);
    }
}

fn duty_from_angle(deg: u32, min_duty: u32, duty_gap: u32) -> u16 {
    let duty = min_duty + ((deg * duty_gap) / 180);
    duty as u16
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку servo-motor.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/servo-motor

Управление сервоприводом с помощью периферии MCPWM ESP32

В предыдущем упражнении мы использовали периферию LEDC ESP32 для управления сервоприводом. В этом упражнении сделаем то же самое с помощью MCPWM. Введение в MCPWM, принцип его работы и соответствующие функции esp-hal описаны здесь. Прочитайте эту главу, прежде чем двигаться дальше.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 servo-mcpwm

Откроется экран с вопросом о выборе опций. В последних версиях esp-hal для mcpwm нужно явно включить нестабильные возможности.

  • Поэтому выберите опцию «Enable unstable HAL features» («Включить нестабильные возможности HAL»).

Затем сохраните настройки, нажав на клавиатуре «s».

Настройка тактирования

Создадим экземпляр тактирования периферии. Настроим тактирование на 32 МГц. Функция сама вычислит предделитель и поделит входную частоту, которая составляет 160 МГц.

#![allow(unused)]
fn main() {
let clock_cfg = PeripheralClockConfig::with_frequency(Rate::from_mhz(32)).unwrap();
}

Для сервопривода нам нужна итоговая частота ШИМ 50 Гц, поэтому базовую частоту тактирования желательно сделать как можно меньше. Можно опуститься до 625 кГц при максимальном значении предделителя 255, но мы оставим 1 МГц, чтобы расчёты были проще.

Настройка MCPWM и вывода

Мы будем использовать периферию MCPWM0, выберем timer0 и operator0. Затем настроим её на GPIO33 и зададим, чтобы ШИМ-сигнал оставался в высоком состоянии до значения временной метки (timestamp), которое мы укажем в цикле ШИМ.

#![allow(unused)]
fn main() {
let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg);
// подключаем operator0 к timer0
mcpwm.operator0.set_timer(&mcpwm.timer0);
// подключаем operator0 к выводу
let mut pwm_pin = mcpwm
    .operator0
    .with_pin_a(peripherals.GPIO33, PwmPinConfig::UP_ACTIVE_HIGH);
}

Настройка таймера

Чтобы получить ШИМ-сигнал 50 Гц для сервопривода при тактовой частоте 1 МГц, таймер должен отсчитать в сумме 20 000 тактов. Поскольку таймер считает от 0 до 19 999, период задаём равным 19_999, что даёт в итоге 20 000 тактов.

#![allow(unused)]
fn main() {
let timer_clock_cfg = clock_cfg
    .timer_clock_with_frequency(19_999, PwmWorkingMode::Increase, Rate::from_hz(50))
    .unwrap();
mcpwm.timer0.start(timer_clock_cfg);
}

Поворот качалки сервопривода

Чтобы повернуть качалку сервопривода, мы меняем временную метку (timestamp) ШИМ-сигнала. Значения временной метки соответствуют нужным углам:

  • Для 0 градусов задаём временную метку 500 (2,5% от 20 000).
  • Для 90 градусов — 1500 (7,5% от 20 000).
  • Для 180 градусов — 2500 (12,5% от 20 000).

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

#![allow(unused)]
fn main() {
loop {
    // 0 градусов (2,5% от 20_000 => 500)
    pwm_pin.set_timestamp(500);
    delay.delay_millis(1500);

    // 90 градусов (7,5% от 20_000 => 1500)
    pwm_pin.set_timestamp(1500);
    delay.delay_millis(1500);

    // 180 градусов (12,5% от 20_000 => 2500)
    pwm_pin.set_timestamp(2500);
    delay.delay_millis(1500);
}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::delay::Delay;
use esp_hal::main;

// MCPWM
use esp_hal::mcpwm::operator::PwmPinConfig;
use esp_hal::mcpwm::timer::PwmWorkingMode;
use esp_hal::mcpwm::{McPwm, PeripheralClockConfig};
use esp_hal::time::Rate;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let delay = Delay::new();

    let clock_cfg = PeripheralClockConfig::with_frequency(Rate::from_mhz(32)).unwrap();
    let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg);

    // подключаем operator0 к timer0
    mcpwm.operator0.set_timer(&mcpwm.timer0);
    // подключаем operator0 к выводу
    let mut pwm_pin = mcpwm
        .operator0
        .with_pin_a(peripherals.GPIO33, PwmPinConfig::UP_ACTIVE_HIGH);

    // запускаем таймер со значениями временной метки в диапазоне 0..=19999 и частотой
    // 50 Гц
    let timer_clock_cfg = clock_cfg
        .timer_clock_with_frequency(19_999, PwmWorkingMode::Increase, Rate::from_hz(50))
        .unwrap();
    mcpwm.timer0.start(timer_clock_cfg);

    loop {
        // 0 градусов (2,5% от 20_000 => 500)
        pwm_pin.set_timestamp(500);
        delay.delay_millis(1500);

        // 90 градусов (7,5% от 20_000 => 1500)
        pwm_pin.set_timestamp(1500);
        delay.delay_millis(1500);

        // 180 градусов (12,5% от 20_000 => 2500)
        pwm_pin.set_timestamp(2500);
        delay.delay_millis(1500);
    }
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку servo-mcpwm.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/servo-mcpwm

АЦП (аналого-цифровой преобразователь, ADC)

При работе со встраиваемыми системами часто нужно взаимодействовать с физическим миром через датчики. Датчики и связанные с ними схемы могут преобразовывать физические величины, такие как температура, давление, свет, звук и ускорение, в электрические сигналы, чаще всего в напряжение. Эти сигналы могут непрерывно меняться, тогда как микроконтроллер обрабатывает цифровые данные. Так как же соединить аналоговый мир с цифровым?

Tip

Аналоговый и цифровой сигналы: если вы не уверены, что означают слова «аналоговый» и «цифровой», у SparkFun есть хорошая статья, объясняющая разницу. Её можно прочитать здесь.

Один из подходов — использовать аналого-цифровой преобразователь (Analog-to-Digital Converter, ADC). АЦП преобразует аналоговое напряжение в цифровое число.

АЦП
Иллюстрация АЦП

Например, предположим, что датчик выдаёт напряжение от 0 В до 3,3 В. АЦП может преобразовать это напряжение в число, которое сможет прочитать наша программа на Rust. Позже мы будем использовать АЦП с такими датчиками, как LDR, для измерения освещённости, и с термистором для измерения температуры.

Как АЦП измеряет напряжение?

Чтобы преобразовать аналоговое напряжение в цифровое число, АЦП нужно известное напряжение в качестве опорного. Оно называется опорным напряжением, или Vref.

АЦП сравнивает входное напряжение с опорным и определяет, в какой части опорного диапазона находится входное напряжение. Затем он выдаёт цифровое число, которое представляет входное напряжение.

Разные типы АЦП используют для этого разные методы. Подробности зависят от типа АЦП и выходят за рамки этой книги.

Tip

Если хотите узнать больше о том, как компараторы используют опорное напряжение (Vref), чтобы АЦП мог определить входное напряжение, посмотрите это видео: Comparators: The Building Blocks of Analog to Digital Converters (ADC)

Например, предположим, что опорное напряжение равно 3,3 В, а входное напряжение — 1,5 В. Входное напряжение составляет примерно 45% от опорного.

При работе с ESP32 есть ещё одна важная деталь. Мы рассмотрим её позже в этой главе. Пока этого достаточно, чтобы понять работу АЦП в упрощённом виде.

Как представляются значения?

АЦП не даёт нам процент от входного напряжения. Вместо этого он выдаёт цифровое число в фиксированном диапазоне. Размер этого диапазона зависит от разрешения АЦП (ADC resolution), которое выражается в битах.

Например, 8-битный АЦП имеет 256 (то есть 2^8) возможных цифровых значений. Они лежат в диапазоне от 0 до 255.

Аналогично:

  • 10-битный АЦП выдаёт значения от 0 до 1023 (1024 возможных значения).
  • 12-битный АЦП выдаёт значения от 0 до 4095 (4096 возможных значений).

Оригинальный ESP32 имеет 12-битные АЦП. Можно также настроить АЦП на использование меньшего количества битов.

Как АЦП представляет разные напряжения?

Входное напряжение может непрерывно меняться, но АЦП способен выдавать только фиксированное количество цифровых значений. Это значит, что близкие входные напряжения могут представляться одним и тем же цифровым значением.

Процесс сопоставления непрерывного напряжения с одним из доступных цифровых значений называется квантованием (quantization).

Например, предположим, что 12-битный АЦП имеет входной диапазон от 0 В до 3,3 В. Он может выдавать 4096 различных значений, от 0 до 4095.

Теперь представьте два входных напряжения, очень близких друг к другу, например 1,500 В и 1,501 В. АЦП может выдать одинаковое цифровое значение для обоих, потому что для представления всего диапазона напряжений доступно лишь конечное число значений.

Это означает, что цифровое значение — приближение реального входного напряжения. Разница между реальным напряжением и напряжением, которое представляет цифровое значение, называется погрешностью квантования (quantization error).

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

Аналого-цифровой преобразователь (ADC) в ESP32

Теперь, когда мы понимаем, как работает АЦП, посмотрим на АЦП оригинального ESP32 и научимся использовать его с Rust.

Оригинальный ESP32 имеет два 12-битных SAR-АЦП и поддерживает до 18 каналов АЦП. Используемая нами плата ESP32 DevKit V1 выводит 15 из них.

Выводы АЦП на ESP32 DevKit V1

Первый АЦП, называемый ADC1, имеет 8 каналов, привязанных к GPIO32–GPIO39. Второй АЦП, ADC2, имеет 10 каналов, привязанных к GPIO0, GPIO2, GPIO4, GPIO12–GPIO15 и GPIO25–GPIO27.

Note

ADC2 и Wi-Fi

При использовании ADC2 есть важное ограничение: аппаратная часть Wi-Fi использует ADC2, поэтому ADC2 нельзя использовать, пока активен Wi-Fi. Если в вашем проекте одновременно используются Wi-Fi и АЦП, используйте выводы ADC1.

На следующей диаграмме показаны выводы АЦП, доступные на ESP32 DevKit V1:

Выводы АЦП ESP32 DevKit V1

Vref и ослабление (Attenuation)

У оригинального ESP32 опорное напряжение АЦП составляет примерно 1,1 В, а не 3,3 В и не напряжение питания. Как мы видели ранее, АЦП использует опорное напряжение, чтобы определить цифровое значение. Но что делать, если нужно измерить напряжение выше этого?

ESP32 может уменьшить входное напряжение до того, как оно попадёт на АЦП. Это уменьшение называется ослаблением (attenuation). Оно позволяет АЦП измерять более широкий диапазон входного напряжения.

Оригинальный ESP32 предоставляет четыре настройки ослабления:

ОслаблениеИзмеряемый диапазон входного напряжения
0 дБ100 мВ ~ 950 мВ
2,5 дБ100 мВ ~ 1250 мВ
6 дБ150 мВ ~ 1750 мВ
11 дБ150 мВ ~ 2450 мВ

У АЦП ESP32 есть и другие ограничения, важные при необходимости точных измерений напряжения: изменение опорного напряжения, шум и калибровка. Здесь мы не будем подробно их разбирать. Подробнее см. документацию ESP-IDF по ADC.

Настройка АЦП в Rust

В esp-hal АЦП настраивается через AdcConfig. Ослабление для вывода АЦП выбирается при его включении.

Например:

#![allow(unused)]
fn main() {
let adc_pin = peripherals.GPIO4;

let mut adc2_config = AdcConfig::new();
let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::_11dB);

let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);
}

Здесь мы настраиваем GPIO4 как вход ADC2 и выбираем ослабление 11 дБ.

Чтение значения АЦП

Когда АЦП настроен, можно считать значение с вывода:

#![allow(unused)]
fn main() {
let adc_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
esp_println::println!("{}", adc_value);
}

АЦП возвращает цифровое значение, а не напряжение. Поскольку АЦП оригинального ESP32 — 12-битный, он может выдавать значения от 0 до 4095.

LDR (фоторезистор)

В этом разделе мы будем использовать LDR (Light Dependent Resistor, он же светозависимый резистор или фоторезистор) с ESP32. LDR меняет своё сопротивление в зависимости от количества света, падающего на него. Чем ярче свет, тем меньше сопротивление, и чем темнее, тем оно больше. Поэтому LDR идеально подходит для таких задач, как измерение освещённости, автоматическое освещение или контроль уровня окружающего света.

фоторезистор

Необходимые компоненты:

  • LDR (светозависимый резистор)
  • Резистор (обычно 10 кОм), нужен для создания делителя напряжения
  • Провода-перемычки (как обычно)

Предварительные требования

Чтобы работать с этим, нужно разобраться, что такое делитель напряжения и как он работает. Также нужно понимать, что такое АЦП и как он функционирует.

Как работает LDR?

Мы уже дали введение в то, что такое LDR. Повторю ещё раз: LDR меняет своё сопротивление в зависимости от количества света, падающего на него. Чем ярче свет, тем меньше сопротивление, и чем темнее, тем оно больше.

Дракула: представьте, что LDR — это Дракула. На солнечном свету он слабеет (точно так же, как сопротивление становится меньше). А в темноте он становится сильнее (точно так же, как сопротивление растёт).

Мы не будем подробно разбирать, из каких полупроводниковых материалов делают LDR и почему он ведёт себя именно так. Рекомендую прочитать эту статью и изучить тему подробнее, если интересно.

Пример выходного напряжения при полной освещённости

Сопротивление LDR низкое при полной освещённости, из-за чего выходное напряжение (\( V_{out} \)) становится заметно ниже.

voltage-divider-ldr1

Пример выходного напряжения при слабом свете

При меньшем количестве света сопротивление LDR растёт, и выходное напряжение увеличивается.

voltage-divider-ldr2

Пример выходного напряжения в полной темноте

В темноте сопротивление LDR высокое, из-за чего выходное напряжение (\( V_{out} \)) выше.

voltage-divider-ldr3

Симуляция LDR в делителе напряжения

Вы можете менять значение яркости и наблюдать, как меняется сопротивление R2 (то есть LDR). Также можно смотреть, как меняется напряжение \( V_{out} \) при увеличении или уменьшении яркости.





50%

Формула: \( V_\text{out} = V_\text{in} \times \frac{R_2}{R_1 + R_2} \)

Подставленная формула: Vout = 3.3 × 999 / (10000 + 999)

Выходное напряжение (Vout): 0.25 В

Circuitjs

Приведённые выше схемы я создал на сайте Falstad. Вы можете импортировать файл схемы, который я сделал, voltage-divider-ldr.circuitjs.txt, на сайт Falstad и поэкспериментировать.

Включаем светодиод (или лампу) при слабом свете с ESP32

В этом упражнении мы будем управлять светодиодом в зависимости от уровня окружающего освещения. Цель — автоматически включать светодиод при слабом свете.

Попробовать это можно в закрытой комнате: включайте и выключайте свет. Когда вы выключаете свет, светодиод должен включиться, если в комнате достаточно темно, и погаснуть, когда свет снова включат. Также можно изменить порог чувствительности или закрыть датчик света (LDR) рукой или каким-нибудь предметом, чтобы имитировать разный уровень освещённости.

Примечание: возможно, порог АЦП придётся подстроить под освещение вашей комнаты и конкретный LDR.

Аппаратные требования

  • Светодиод — любой стандартный светодиод (цвет на ваш выбор).
  • LDR (светозависимый резистор) — используется для определения интенсивности света.
  • Резисторы
    • 330 Ом — для светодиода, чтобы ограничить ток и не допустить повреждения. (Может потребоваться подбор в зависимости от светодиода.)
    • 10 кОм — для LDR; вместе с ним образует делитель напряжения.
  • Провода-перемычки — для соединения компонентов на макетной плате или с микроконтроллером.

Схема подключения светодиода и LDR к ESP32

Подключение LDR к ESP32:

  1. Один вывод LDR подключён к земле.
  2. Другой вывод LDR подключён к GPIO 4 (ADC2) (то есть D4 на DevKit).
  3. Резистор подключён последовательно с LDR, образуя делитель напряжения между LDR и 3V3.
схема подключения ESP32 с фоторезистором

Подключение светодиода к ESP32:

Это обычная схема, которую мы уже делали раньше. Катод (короткую ножку) светодиода нужно подключить к земле. Затем анод (длинную ножку) через резистор 330 Ом подключить к GPIO 33 (то есть D33 на DevKit).

Включаем светодиод в темноте с фоторезистором и ESP32 на Rust

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 ldr-dracula

Откроется экран с вопросом о выборе опций. В последних версиях esp-hal для ADC нужно явно включить нестабильные возможности.

  • Поэтому выберите опцию «Enable unstable HAL features» («Включить нестабильные возможности HAL»).

В этом упражнении мы будем выводить сообщения в системную консоль. Для этого нужно включить функцию логирования.

  • Прокрутите список до пункта «Flashing, logging and debugging (espflash)» и нажмите Enter.

  • Затем выберите «Use defmt to print messages» («Использовать defmt для вывода сообщений»).

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

Крейт nb упрощает неблокирующий ввод-вывод (например, чтение датчиков или данных UART): он возвращает nb::Result с ошибкой WouldBlock, когда операция ещё не готова, что позволяет повторить её позже без блокировки.

Если интересно, зачем он нужен: функция read_oneshot у АЦП возвращает nb::Result и может вернуть nb::Error::WouldBlock, если результат ещё не готов. Обернув её в nb::block, мы заставляем код повторять попытки, пока АЦП не закончит работу и не вернёт нормальный результат.

nb = "1.1.0"

Настройка светодиода

Мы уже делали это раньше: настроим GPIO 33, к которому подключён светодиод, как выходной вывод и инициализируем его в состоянии Low.

#![allow(unused)]
fn main() {
let mut led = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
}

Настройка АЦП

Настроим GPIO 4 как вход АЦП — это один из каналов ADC2. Применим ослабление 11 дБ, которое позволяет АЦП измерять входное напряжение от 150 мВ до ~2450 мВ.

#![allow(unused)]
fn main() {
let adc_pin = peripherals.GPIO4;
let mut adc2_config = AdcConfig::new();
let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::_11dB);
let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);
}

Одиночное чтение (oneshot)

Функция read_oneshot запускает одно преобразование АЦП на заданном выводе. Она неблокирующая и возвращает 16-битное значение, обёрнутое в Result. Однако мы будем использовать nb::block, чтобы дождаться завершения преобразования, и только потом продолжить.

#![allow(unused)]
fn main() {
let pin_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
}

Переключение светодиода

Получив цифровое значение, включить или выключить светодиод очень просто. Мы включим светодиод, переведя его в High, если значение вывода больше 3500 (порог можно менять по необходимости). В противном случае выключим его. Порог 3500 обычно достигается, когда в комнате темно и сопротивление LDR (R2) высокое, из-за чего напряжение выше.

#![allow(unused)]
fn main() {
if pin_value > 3500 {
    led.set_high();
} else {
    led.set_low();
}
}

Если непонятно, почему высокое сопротивление LDR приводит к более высокому напряжению, перечитайте раздел «Как это работает» и поэкспериментируйте с симулятором.

Итоговый код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::delay::Delay;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::main;
use esp_println as _;

// ADC
use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let mut led = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());

    let adc_pin = peripherals.GPIO4;
    let mut adc2_config = AdcConfig::new();
    let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::_11dB);
    let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);
    let delay = Delay::new();

    loop {
        let pin_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
        esp_println::println!("{}", pin_value);

        if pin_value > 3500 {
            led.set_high();
        } else {
            led.set_low();
        }

        delay.delay_millis(500);
    }
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку ldr-dracula.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/ldr-dracula

Wi-Fi

До сих пор мы не говорили об одной из главных сильных сторон чипа ESP32: его поддержке Wi-Fi. В этом разделе мы рассмотрим возможности Wi-Fi ESP32 и то, чего с его помощью можно добиться.

ESP32 поддерживает стандартные протоколы Wi-Fi (802.11 b/g/n) и может работать в двух режимах: режиме станции (Station, STA) и режиме программной точки доступа (Soft Access Point). Также он может работать в обоих режимах одновременно.

Режим станции (STA)

В этом режиме ESP32 подключается к существующей сети Wi-Fi как клиент, так же как ваш смартфон или ноутбук подключается к Wi-Fi-роутеру. После подключения ESP32 может получить доступ к интернету или обмениваться данными с другими устройствами в той же сети.

Режим станции (STA) Wi-Fi на ESP32

Режим точки доступа (AP)

В этом режиме ESP32 выступает как точка доступа Wi-Fi: он создаёт собственную сеть, к которой могут подключаться другие устройства. По сути, ESP32 работает как роутер, позволяя смартфонам, ноутбукам или другим микроконтроллерам (например, другому ESP32) напрямую обмениваться с ним данными.

Режим точки доступа (AP) Wi-Fi на ESP32

Как подключить ESP32 к Wi-Fi и открыть сайт

В этом упражнении мы настроим ESP32 в режиме STA для подключения к вашей Wi-Fi-сети. Затем отправим запрос с ESP32 на веб-сервер и выведем ответ в системную консоль. Этот код основан на примерах esp-hal.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 wifi-webfetch

Откроется экран с вопросом о выборе опций. Чтобы включить Wi-Fi, сначала нужно включить функции «unstable» и «alloc». Как вы могли заметить, пока не выбраны эти две опции, пункт Wi-Fi недоступен. Поэтому выбирайте их по одной:

  • Сначала выберите опцию «Enable unstable HAL features».
  • Выберите опцию «Enable allocations via the esp-alloc crate».
  • Теперь можно включить «Enable Wi-Fi via the esp-radio crate».

Также включите логирование.

  • Прокрутите до пункта «Flashing, logging and debugging (espflash)» и нажмите Enter.
  • Затем выберите «Use defmt to print messages».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем зависимости

Добавьте в файл Cargo.toml следующие зависимости:

blocking-network-stack = { git = "https://github.com/bjoernQ/blocking-network-stack.git", rev = "b3ecefc222d8806edd221f266999ca339c52d34e", default-features = false, features = [
  "dhcpv4",
  "tcp",
] }

heapless = { version = "0.9.1" }

blocking-network-stack — это крейт, который предоставляет синхронные (не асинхронные) сетевые примитивы для TCP/UDP-обмена.

Из-за конфликта версий я понизил embedded-io до версии 0.6.1 (вместо 0.7.1, которую сгенерировал esp-generate).

embedded-io = { version = "0.6.1" }

Крейт esp-alloc

«Простой аллокатор кучи no_std для процессоров RISC-V и Xtensa от Espressif. Поддерживает все доступные на данный момент устройства ESP32». Крейт esp-alloc предоставляет глобальный аллокатор для SoC ESP.

Инициализация периферии

Создадим вспомогательную функцию, которая инициализирует ESP HAL и возвращает экземпляр периферии. Также настроим кучу размером 72 КиБ с помощью макроса esp_alloc::heap_allocator!.

#![allow(unused)]
fn main() {
fn init_hardware() -> Peripherals {
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);
    esp_alloc::heap_allocator!(size: 72 * 1024);
    peripherals
}
}

Инициализация контроллера Wi-Fi

Программе нужно передать имя Wi-Fi и пароль. Вместо того чтобы жёстко прописывать эти учётные данные в коде, загрузим их из переменных окружения. При запуске программы передадим переменные окружения вместе с ней.

#![allow(unused)]
fn main() {
const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");
}

Сначала инициализируем TimerGroup и генератор случайных чисел (RNG), которые нужны для настройки контроллера Wi-Fi. RNG также используется для инициализации синхронного стека TCP/IP, поэтому используем clone, чтобы можно было переиспользовать его.

#![allow(unused)]
fn main() {
let timg0 = TimerGroup::new(peripherals.TIMG0);
let rng = Rng::new();
}

Инициализируем контроллер Wi-Fi, используя аппаратный таймер, RNG и периферию тактирования. Затем создаём экземпляр драйвера Wi-Fi, который управляет сетевыми подключениями и различными интерфейсами. Наконец, настраиваем устройство на работу в режиме станции (STA), чтобы оно могло подключаться к сетям Wi-Fi как клиент.

#![allow(unused)]
fn main() {
esp_rtos::start(timg0.timer0);
let radio_init = esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller");

let (mut wifi_controller, interfaces) =
    esp_radio::wifi::new(&radio_init, peripherals.WIFI, Default::default())
        .expect("Failed to initialize Wi-Fi controller");

let mut device = interfaces.sta;
}

Инициализация SocketSet

Создадим SocketSet с местом для трёх сокетов, чтобы управлять несколькими сокетами внутри стека, например DHCP и TCP.

#![allow(unused)]
fn main() {
let mut socket_set_entries: [SocketStorage; 3] = Default::default();
let mut socket_set = SocketSet::new(&mut socket_set_entries[..]);
}

DHCP-сокет

Создадим DHCP-сокет, чтобы запросить IP-адрес у DHCP-сервера. Можно задать исходящие опции, в том числе имя хоста, через DHCP Client Option 12 (подробнее о DHCP-опциях — здесь). Добавим DHCP-сокет в SocketSet, который создали ранее.

#![allow(unused)]
fn main() {
let mut dhcp_socket = smoltcp::socket::dhcpv4::Socket::new();
// здесь можно задать имя хоста (или добавить другие DHCP-опции)
dhcp_socket.set_outgoing_options(&[DhcpOption {
    kind: 12,
    data: b"implRust",
}]);
socket_set.add(dhcp_socket);
// socket_set.add(smoltcp::socket::dhcpv4::Socket::new());
}

Инициализация сетевого стека

Инициализируем Stack из крейта smoltcp, используя сетевой интерфейс и устройство (которые мы получили из Wi-Fi-интерфейса, созданного ранее), сокеты, замыкание для получения времени и случайное число.

#![allow(unused)]
fn main() {
let now = || Instant::now().duration_since_epoch().as_millis();
let mut stack = Stack::new(
    create_interface(&mut device),
    device,
    socket_set,
    now,
    rng.random(),
);
}

Шаблонные функции для настройки сетевого интерфейса стека TCP/IP smoltcp с Wi-Fi-устройством (взяты из официальных примеров esp-hal)

#![allow(unused)]
fn main() {
pub fn create_interface(device: &mut esp_wifi::wifi::WifiDevice) -> smoltcp::iface::Interface {
    // можно создать несколько экземпляров, но так как WifiDevice один, сделать с ним что-то плохое вряд ли получится
    // скорее всего, с ним нельзя сделать ничего плохого
    smoltcp::iface::Interface::new(
        smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ethernet(
            smoltcp::wire::EthernetAddress::from_bytes(&device.mac_address()),
        )),
        device,
        timestamp(),
    )
}

// шаблонный код smoltcp
fn timestamp() -> smoltcp::time::Instant {
    smoltcp::time::Instant::from_micros(
        esp_hal::time::Instant::now()
            .duration_since_epoch()
            .as_micros() as i64,
    )
}
}

Режим работы Wi-Fi:

Далее настраиваем режим работы Wi-Fi с помощью перечисления ModeConfig. Поскольку ESP32 должен работать как Wi-Fi-клиент, используем вариант Client. Нужно указать SSID (идентификатор сети, то есть имя вашей сети Wi-Fi) и пароль Wi-Fi.

#![allow(unused)]
fn main() {
fn configure_wifi(controller: &mut WifiController<'_>) {
    controller
        .set_power_saving(esp_radio::wifi::PowerSaveMode::None)
        .unwrap();

    let client_config = ModeConfig::Client(
        ClientConfig::default()
            .with_ssid(SSID.into())
            .with_password(PASSWORD.into()),
    );
    let res = controller.set_config(&client_config);
    println!("wifi_set_configuration returned {:?}", res);

    controller.start().unwrap();
    println!("is wifi started: {:?}", controller.is_started());
}
}

Сканирование Wi-Fi и подключение к точке доступа

Далее выполняем блокирующее сканирование Wi-Fi-сетей с параметрами по умолчанию. Это значит, что программа остановится и будет ждать, пока сканирование не завершится. Когда сканирование закончится, будет возвращён список доступных точек доступа Wi-Fi. Затем мы проходим по результатам и выводим информацию о каждой точке доступа в консоль.

#![allow(unused)]
fn main() {
fn scan_wifi(controller: &mut WifiController<'_>) {
    println!("Start Wifi Scan");
    let scan_config = ScanConfig::default().with_max(10);
    let res = controller.scan_with_config(scan_config).unwrap();
    for ap in res {
        println!("{:?}", ap);
    }
}
}

Подключение к Wi-Fi

Затем выводим поддерживаемые возможности контроллера: будет показан только «Client», так как именно его мы и настроили.

После этого вызываем метод connect у контроллера Wi-Fi. Это подключит ESP32 к сети Wi-Fi, которую мы указали ранее.

В цикле будем постоянно проверять, подключён ли Wi-Fi. Как только подключение установится, перейдём дальше.

#![allow(unused)]
fn main() {
fn connect_wifi(controller: &mut WifiController<'_>) {
    println!("{:?}", controller.capabilities());
    println!("wifi_connect {:?}", controller.connect());

    println!("Wait to get connected");
    loop {
        match controller.is_connected() {
            Ok(true) => break,
            Ok(false) => {}
            Err(err) => panic!("{:?}", err),
        }
    }
    println!("Connected: {:?}", controller.is_connected());
}
}

Получение IP-адреса

Нужно получить IP-адрес, поэтому сначала ждём завершения процесса DHCP. Будем в цикле проверять, поднялся ли интерфейс и получили ли мы IP-адрес.

#![allow(unused)]
fn main() {
fn obtain_ip(stack: &mut Stack<'_, esp_radio::wifi::WifiDevice<'_>>) {
    println!("Wait for IP address");
    loop {
        stack.work();
        if stack.is_iface_up() {
            println!("IP acquired: {:?}", stack.get_ip_info());
            break;
        }
    }
}
}

На этом этапе ESP32 должен быть успешно подключён к сети Wi-Fi. Далее настроим код, который отправит HTTP-запрос к сайту. После отправки запроса ESP32 получит HTML-ответ от сайта, и мы выведем HTML-содержимое в консоль.

HTTP-клиент

Чтобы отправить веб-запрос на сайт, нужен HTTP-клиент. Для этого мы используем крейт smoltcp, чтобы отправить «сырой» HTTP-запрос через TCP-сокет.

«smoltcp — это самостоятельный управляемый событиями стек TCP/IP, предназначенный для bare-metal и систем реального времени. smoltcp вообще не требует выделения кучи, обширно документирован и компилируется на стабильном Rust 1.80 и новее».

TCP-сокет

Инициализируем TCP-сокет с буферами чтения и записи для входящих и исходящих данных сокета.

#![allow(unused)]
fn main() {
let mut rx_buffer = [0u8; 1536];
let mut tx_buffer = [0u8; 1536];
let socket = stack.get_socket(&mut rx_buffer, &mut tx_buffer);

http_loop(socket)
}

Цикл HTTP: отправка GET-запроса

Отправим HTTP-запрос методом GET на сайт «www.mobile-j.de». Этот сайт используется в примерах esp-hal: он возвращает «Hello fellow Rustaceans!» с ASCII-арт Ferris. Откроем соединение на порту 80 по IP-адресу сайта и затем отправим HTTP-запрос.

#![allow(unused)]
fn main() {
println!("Making HTTP request");
socket.work();

let remote_addr = IpAddress::v4(142, 250, 185, 115);
socket.open(remote_addr, 80).unwrap();
socket
    .write(b"GET / HTTP/1.0\r\nHost: www.mobile-j.de\r\n\r\n")
    .unwrap();
socket.flush().unwrap();
}

Чтение ответа

Далее читаем ответ сервера с таймаутом 20 секунд.

#![allow(unused)]
fn main() {
let deadline = Instant::now() + Duration::from_secs(20);
let mut buffer = [0u8; 512];
while let Ok(len) = socket.read(&mut buffer) {
    // let text = unsafe { core::str::from_utf8_unchecked(&buffer[..len]) };
    let Ok(text) = core::str::from_utf8(&buffer[..len]) else {
        panic!("Invalid UTF-8 sequence encountered");
    };

    println!("{}", text);

    if Instant::now() > deadline {
        println!("Timeout");
        break;
    }
}
}

Закрытие сокета

После завершения HTTP-запроса закрываем сокет и ждём 5 секунд.

#![allow(unused)]
fn main() {
socket.disconnect();
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
    socket.work();
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку wifi-webfetch.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/wifi-webfetch

Как запустить?

Обычно мы просто запускаем cargo run --release, но на этот раз нужно также передать переменные окружения для подключения к Wi-Fi.

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD  cargo run --release

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use blocking_network_stack::Stack;
use embedded_io::{Read, Write};
use esp_hal::clock::CpuClock;
use esp_hal::delay::Delay;
use esp_hal::main;
use esp_hal::peripherals::Peripherals;
use esp_hal::rng::Rng;
use esp_hal::time::{Duration, Instant};
use esp_hal::timer::timg::TimerGroup;
use esp_println::{self as _, println};
use esp_radio::wifi::{ClientConfig, ModeConfig, ScanConfig, WifiController};
use smoltcp::iface::{SocketSet, SocketStorage};
use smoltcp::wire::{DhcpOption, IpAddress};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern crate alloc;

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let peripherals = init_hardware();

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    let rng = Rng::new();

    esp_rtos::start(timg0.timer0);
    let radio_init = esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller");

    let (mut wifi_controller, interfaces) =
        esp_radio::wifi::new(&radio_init, peripherals.WIFI, Default::default())
            .expect("Failed to initialize Wi-Fi controller");

    let mut device = interfaces.sta;

    // let mut stack = setup_network_stack(device, &mut rng);
    let mut socket_set_entries: [SocketStorage; 3] = Default::default();
    let mut socket_set = SocketSet::new(&mut socket_set_entries[..]);
    let mut dhcp_socket = smoltcp::socket::dhcpv4::Socket::new();

    // здесь можно задать имя хоста (или добавить другие DHCP-опции)
    dhcp_socket.set_outgoing_options(&[DhcpOption {
        kind: 12,
        data: b"implRust",
    }]);
    socket_set.add(dhcp_socket);
    // sta_socket_set.add(smoltcp::socket::dhcpv4::Socket::new());

    let now = || Instant::now().duration_since_epoch().as_millis();
    let mut stack = Stack::new(
        create_interface(&mut device),
        device,
        socket_set,
        now,
        rng.random(),
    );

    configure_wifi(&mut wifi_controller);
    scan_wifi(&mut wifi_controller);
    connect_wifi(&mut wifi_controller);
    obtain_ip(&mut stack);

    let mut rx_buffer = [0u8; 1536];
    let mut tx_buffer = [0u8; 1536];
    let socket = stack.get_socket(&mut rx_buffer, &mut tx_buffer);

    http_loop(socket)
}

pub fn create_interface(device: &mut esp_radio::wifi::WifiDevice) -> smoltcp::iface::Interface {
    // можно создать несколько экземпляров, но так как WifiDevice один, сделать с ним что-то плохое вряд ли получится
    // скорее всего, с ним нельзя сделать ничего плохого
    smoltcp::iface::Interface::new(
        smoltcp::iface::Config::new(smoltcp::wire::HardwareAddress::Ethernet(
            smoltcp::wire::EthernetAddress::from_bytes(&device.mac_address()),
        )),
        device,
        timestamp(),
    )
}

// шаблонный код smoltcp
fn timestamp() -> smoltcp::time::Instant {
    smoltcp::time::Instant::from_micros(
        esp_hal::time::Instant::now()
            .duration_since_epoch()
            .as_micros() as i64,
    )
}

fn init_hardware() -> Peripherals {
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);
    esp_alloc::heap_allocator!(size: 72 * 1024);
    peripherals
}

fn configure_wifi(controller: &mut WifiController<'_>) {
    controller
        .set_power_saving(esp_radio::wifi::PowerSaveMode::None)
        .unwrap();

    let client_config = ModeConfig::Client(
        ClientConfig::default()
            .with_ssid(SSID.into())
            .with_password(PASSWORD.into()),
    );
    let res = controller.set_config(&client_config);
    println!("wifi_set_configuration returned {:?}", res);

    controller.start().unwrap();
    println!("is wifi started: {:?}", controller.is_started());
}

fn scan_wifi(controller: &mut WifiController<'_>) {
    println!("Start Wifi Scan");
    let scan_config = ScanConfig::default().with_max(10);
    let res = controller.scan_with_config(scan_config).unwrap();
    for ap in res {
        println!("{:?}", ap);
    }
}

fn connect_wifi(controller: &mut WifiController<'_>) {
    println!("{:?}", controller.capabilities());
    println!("wifi_connect {:?}", controller.connect());

    println!("Wait to get connected");
    loop {
        match controller.is_connected() {
            Ok(true) => break,
            Ok(false) => {}
            Err(err) => panic!("{:?}", err),
        }
    }
    println!("Connected: {:?}", controller.is_connected());
}

fn obtain_ip(stack: &mut Stack<'_, esp_radio::wifi::WifiDevice<'_>>) {
    println!("Wait for IP address");
    loop {
        stack.work();
        if stack.is_iface_up() {
            println!("IP acquired: {:?}", stack.get_ip_info());
            break;
        }
    }
}

fn http_loop(
    mut socket: blocking_network_stack::Socket<'_, '_, esp_radio::wifi::WifiDevice<'_>>,
) -> ! {
    println!("Starting HTTP client loop");
    let delay = Delay::new();
    loop {
        println!("Making HTTP request");
        socket.work();

        let remote_addr = IpAddress::v4(142, 250, 185, 115);
        socket.open(remote_addr, 80).unwrap();
        socket
            .write(b"GET / HTTP/1.0\r\nHost: www.mobile-j.de\r\n\r\n")
            .unwrap();
        socket.flush().unwrap();

        let deadline = Instant::now() + Duration::from_secs(20);
        let mut buffer = [0u8; 512];
        while let Ok(len) = socket.read(&mut buffer) {
            // let text = unsafe { core::str::from_utf8_unchecked(&buffer[..len]) };
            let Ok(text) = core::str::from_utf8(&buffer[..len]) else {
                panic!("Invalid UTF-8 sequence encountered");
            };

            println!("{}", text);

            if Instant::now() > deadline {
                println!("Timeout");
                break;
            }
        }

        socket.disconnect();
        let deadline = Instant::now() + Duration::from_secs(5);
        while Instant::now() < deadline {
            socket.work();
        }

        delay.delay_millis(1000);
    }
}

Подключение ESP32 к Wi-Fi и асинхронный доступ к сайтам с Embassy

В этом упражнении мы снова используем режим STA для доступа к сайту, но на этот раз сделаем это асинхронно. В качестве HTTP-клиента будем использовать крейт reqwless, а для поддержки асинхронности во встраиваемых системах — фреймворк Embassy. Кроме них, подключим и другие вспомогательные крейты.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 wifi-async-http

Откроется экран с вопросом о выборе опций. Чтобы включить Wi-Fi, сначала нужно включить функции «unstable» и «alloc». Как вы могли заметить, пока не выбраны эти две опции, пункт Wi-Fi недоступен. Поэтому выбирайте их по одной:

  • Сначала выберите опцию «Enable unstable HAL features».
  • Выберите опцию «Enable allocations via the esp-alloc crate».
  • Теперь можно включить «Enable Wi-Fi via esp-radio crate».
  • Выберите опцию «Adds embassy framework support».

Также включите логирование.

  • Прокрутите до пункта «Flashing, logging and debugging (espflash)» и нажмите Enter.
  • Затем выберите «Use defmt to print messages».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем зависимости

Embassy net

embassy-net, построенный на крейте smoltcp, предоставляет асинхронный сетевой стек для встраиваемых систем. Он предлагает API более высокого уровня и поддерживает IPv4, IPv6, среды Ethernet и bare-IP, а также TCP, UDP, DNS, DHCPv4 и multicast.

Этот крейт добавляется автоматически, если при генерации проекта с помощью esp-generate вы выбрали опции Wi-Fi и Embassy. Однако нам нужна ещё одна функция: «dns». Она необходима для HTTP-запросов, потому что имя сайта (например, google.com) нужно преобразовать в IP-адрес. Напомню, что в предыдущем упражнении мы указывали IP-адрес сайта вручную.

# Обновлённый embassy-net в Cargo.toml
embassy-net = { version = "0.7.1", features = [
  "defmt",
  "dhcpv4",
  "medium-ethernet",
  "tcp",
  "udp",
  #добавлено:
  "dns",
] }

smoltcp

Крейт smoltcp тоже добавляется в Cargo.toml. Чтобы использовать DNS-серверы, нужно включить feature «dns-max-server-count-4».

# Обновлённый smoltcp в Cargo.toml
smoltcp = { version = "0.12.0", default-features = false, features = [
    "defmt",
    "medium-ethernet",
    "multicast",
    "proto-dhcpv4",
    "proto-dns",
    "proto-ipv4",
    "socket-dns",
    "socket-icmp",
    "socket-raw",
    "socket-tcp",
    "socket-udp",
    # добавлено:
    "dns-max-server-count-4", 
] }

Reqwless

Крейт reqwless — это HTTP-клиент для встраиваемых систем, который работает с любым транспортом, реализующим трейты из крейта embedded-io.

reqwless = { version = "0.13.0", default-features = false, features = [
    "embedded-tls",
] }

Подключение ESP32 к Wi-Fi с поддержкой Embassy

Итак, мы обсудили зависимости, которые нужны для этого упражнения, и функции, которые нужно включить. Теперь перейдём к коду. Сначала посмотрим код, который подключает Wi-Fi с помощью Embassy.

Вспомогательный макрос для StaticCell

Во встраиваемой среде крейт StaticCell полезен, когда нужно инициализировать переменную во время выполнения, но при этом требуется, чтобы она имела статическое время жизни. Мы определим макрос, который создаёт глобально доступные статические переменные. Макрос принимает два аргумента: тип переменной и значение, которым её нужно инициализировать. Функция uninit возвращает изменяемую ссылку на неинициализированную память, и мы записываем в неё значение.

#![allow(unused)]
fn main() {
// Если устраивает nightly-компилятор, можно использовать макрос из крейта static_cell: https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html

macro_rules! mk_static {
    ($t:ty,$val:expr) => {{
        static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
        #[deny(unused_attributes)]
        let x = STATIC_CELL.uninit().write(($val));
        x
    }};
}
}

Инициализация контроллера Wi-Fi

Инициализируем Embassy обычным образом:

#![allow(unused)]
fn main() {
let timg0 = TimerGroup::new(peripherals.TIMG0);
esp_rtos::start(timg0.timer0);
}

Загрузите учётные данные Wi-Fi из переменных окружения:

#![allow(unused)]
fn main() {
const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");
}

Далее инициализируем контроллер радиомодуля со статическим временем жизни. Мы используем макрос mk_static!, чтобы сделать контроллер 'static, потому что стек Wi-Fi работает как асинхронная задача, которая непрерывно обрабатывает сетевые события на протяжении всей работы программы. Поэтому переменная radio_init должна оставаться действительной всё время работы программы.

#![allow(unused)]
fn main() {
// let radio_init = esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller");
let radio_init = &*mk_static!(
    esp_radio::Controller<'static>,
    esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller")
);
}

Далее создаём контроллер Wi-Fi вместе с его сетевыми интерфейсами, а затем выделяем интерфейс STA.

#![allow(unused)]
fn main() {
let (wifi_controller, interfaces) =
        esp_radio::wifi::new(&radio_init, peripherals.WIFI, Default::default())
            .expect("Failed to initialize Wi-Fi controller");
let wifi_interface = interfaces.sta;
}

Инициализация сетевого стека

Случайное число нужно и для конфигурации TLS, и для инициализации сетевого стека, и оба требуют u64. Однако rng генерирует только значения u32, поэтому мы получаем два случайных числа и с помощью побитовой операции помещаем одно в старшие биты (MSB), а другое — в младшие (LSB):

#![allow(unused)]
fn main() {
let rng = Rng::new();
let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32);
let tls_seed = rng.random() as u64 | ((rng.random() as u64) << 32);
}

Инициализируем сетевой стек из крейта embassy_net, используя сетевой интерфейс, полученный от контроллера Wi-Fi, случайное число в качестве seed, конфигурацию DHCP и ресурсы стека размером 3.

#![allow(unused)]
fn main() {
 let dhcp_config = DhcpConfig::default();
// dhcp_config.hostname = Some(String::from_str("implRust").unwrap());

let config = embassy_net::Config::dhcpv4(dhcp_config);
// Инициализируем сетевой стек
let (stack, runner) = embassy_net::new(
    wifi_interface,
    config,
    mk_static!(StackResources<3>, StackResources::<3>::new()),
    net_seed,
);
}

Далее запустим две фоновые задачи: connection_task будет поддерживать подключение к Wi-Fi, а net_task будет запускать сетевой стек и обрабатывать сетевые события.

#![allow(unused)]
fn main() {
spawner.spawn(connection(controller)).ok();
spawner.spawn(net_task(runner)).ok();
}

Вскоре мы разберём, что происходит в этих двух задачах, и посмотрим на определения функций. Но сначала завершим общий ход работы.

Ожидание подключения к Wi-Fi

Будем ждать, пока соединение Wi-Fi не поднимется, а затем получим IP-адрес.

#![allow(unused)]
fn main() {
async fn wait_for_connection(stack: Stack<'_>) {
    println!("Waiting for link to be up");
    loop {
        if stack.is_link_up() {
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }

    println!("Waiting to get IP address...");
    loop {
        if let Some(config) = stack.config_v4() {
            println!("Got IP: {}", config.address);
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }
}
}

Задачи подключения к Wi-Fi

Функция connection_task управляет подключением к Wi-Fi: она непрерывно проверяет состояние, настраивает контроллер Wi-Fi и пытается переподключиться, если соединение потеряно или Wi-Fi не запущен.

  1. Сначала проверяем состояние Wi-Fi. Если оно StaConnected, ждём, пока соединение не будет разорвано. Если соединение разорвано, переходим к остальным шагам цикла.
  2. Проверяем, запущен ли контроллер Wi-Fi. Если нет, инициализируем клиентскую конфигурацию Wi-Fi с SSID (именем сети) и паролем и запускаем его.
  3. Наконец, пытаемся подключиться к Wi-Fi.
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) {
    println!("start connection task");
    println!("Device capabilities: {:?}", controller.capabilities());
    loop {
        match esp_radio::wifi::sta_state() {
            WifiStaState::Connected => {
                // ждём, пока соединение не будет разорвано
                controller.wait_for_event(WifiEvent::StaDisconnected).await;
                Timer::after(Duration::from_millis(5000)).await
            }
            _ => {}
        }
        if !matches!(controller.is_started(), Ok(true)) {
            let client_config = ModeConfig::Client(
                ClientConfig::default()
                    .with_ssid(SSID.into())
                    .with_password(PASSWORD.into()),
            );
            controller.set_config(&client_config).unwrap();
            println!("Starting wifi");
            controller.start_async().await.unwrap();
            println!("Wifi started!");

            println!("Scan");
            let scan_config = ScanConfig::default().with_max(10);
            let result = controller
                .scan_with_config_async(scan_config)
                .await
                .unwrap();
            for ap in result {
                println!("{:?}", ap);
            }
        }
        println!("About to connect...");

        match controller.connect_async().await {
            Ok(_) => println!("Wifi connected!"),
            Err(e) => {
                println!("Failed to connect to wifi: {:?}", e);
                Timer::after(Duration::from_millis(5000)).await
            }
        }
    }
}
}

Запуск сетевого стека

#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
    runner.run().await
}
}

Финал: доступ к сайту

Мы разобрали, как настроить подключение к Wi-Fi с помощью Embassy. Поскольку предыдущая глава получилась довольно длинной, мы не стали подробно разбирать функцию access_website. В этой главе сосредоточимся на том, как отправить HTTP-запрос и получить ответ от сайта с помощью крейта reqwless, а затем наконец запустим код.

Отправка HTTP-запроса

Начнём с инициализации DNS-сокета и TCP-клиента.

Конфигурация TLS:

Далее настраиваем конфигурацию TLS, используя tls_seed — случайное число, которое мы сгенерировали ранее и передали в функцию. Мы настраиваем TLS так, чтобы пропустить проверку SSL-сертификата, установив TlsVerify::None. Это не рекомендуется для рабочих окружений, но для простоты мы используем такую небезопасную настройку. В рабочем окружении нужно настроить TLS с проверкой сертификатов и передать цепочку сертификатов, чтобы её можно было использовать для проверки.

Остальное достаточно просто. Мы инициализируем HTTP-клиент с TCP-клиентом, DNS-сокетом и конфигурацией TLS. Затем отправляем GET-запрос на сайт jsonplaceholder, который возвращает содержимое в формате JSON. Мы читаем содержимое и выводим его в консоль.

#![allow(unused)]
fn main() {
async fn access_website(stack: Stack<'_>, tls_seed: u64) {
    let mut rx_buffer = [0; 4096];
    let mut tx_buffer = [0; 4096];
    let dns = DnsSocket::new(stack);
    let tcp_state = TcpClientState::<1, 4096, 4096>::new();
    let tcp = TcpClient::new(stack, &tcp_state);

    let tls = TlsConfig::new(
        tls_seed,
        &mut rx_buffer,
        &mut tx_buffer,
        reqwless::client::TlsVerify::None,
    );

    let mut client = HttpClient::new_with_tls(&tcp, &dns, tls);
    let mut buffer = [0u8; 4096];
    let mut http_req = client
        .request(
            reqwless::request::Method::GET,
            "https://jsonplaceholder.typicode.com/posts/1",
        )
        .await
        .unwrap();
    let response = http_req.send(&mut buffer).await.unwrap();

    info!("Got response");
    let res = response.body().read_to_end().await.unwrap();

    let content = core::str::from_utf8(res).unwrap();
    println!("{}", content);
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку wifi-async-http.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/wifi-async-http

Как запустить?

Обычно мы просто запускаем cargo run --release, но на этот раз нужно также передать переменные окружения для подключения к Wi-Fi.

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD  cargo run --release

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_net::{
    DhcpConfig, Runner, Stack, StackResources,
    dns::DnsSocket,
    tcp::client::{TcpClient, TcpClientState},
};
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::rng::Rng;
use esp_hal::timer::timg::TimerGroup;
use esp_println::{self as _, println};
use esp_radio::wifi::{
    ClientConfig, ModeConfig, ScanConfig, WifiController, WifiDevice, WifiEvent, WifiStaState,
};
use reqwless::client::{HttpClient, TlsConfig};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern crate alloc;

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

// Если устраивает nightly-компилятор, можно использовать макрос из крейта static_cell: https://docs.rs/static_cell/2.1.0/static_cell/macro.make_static.html

macro_rules! mk_static {
    ($t:ty,$val:expr) => {{
        static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
        #[deny(unused_attributes)]
        let x = STATIC_CELL.uninit().write(($val));
        x
    }};
}

const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    // let radio_init = esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller");
    let rng = Rng::new();
    let radio_init = &*mk_static!(
        esp_radio::Controller<'static>,
        esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller")
    );

    let (wifi_controller, interfaces) =
        esp_radio::wifi::new(&radio_init, peripherals.WIFI, Default::default())
            .expect("Failed to initialize Wi-Fi controller");

    let wifi_interface = interfaces.sta;

    let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32);
    let tls_seed = rng.random() as u64 | ((rng.random() as u64) << 32);

    let dhcp_config = DhcpConfig::default();
    let config = embassy_net::Config::dhcpv4(dhcp_config);

    // Инициализируем сетевой стек
    let (stack, runner) = embassy_net::new(
        wifi_interface,
        config,
        mk_static!(StackResources<3>, StackResources::<3>::new()),
        net_seed,
    );

    spawner.spawn(connection(wifi_controller)).ok();
    spawner.spawn(net_task(runner)).ok();

    wait_for_connection(stack).await;

    access_website(stack, tls_seed).await;

    loop {}
}

async fn wait_for_connection(stack: Stack<'_>) {
    println!("Waiting for link to be up");
    loop {
        if stack.is_link_up() {
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }

    println!("Waiting to get IP address...");
    loop {
        if let Some(config) = stack.config_v4() {
            println!("Got IP: {}", config.address);
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }
}

#[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) {
    println!("start connection task");
    println!("Device capabilities: {:?}", controller.capabilities());
    loop {
        match esp_radio::wifi::sta_state() {
            WifiStaState::Connected => {
                // ждём, пока соединение не будет разорвано
                controller.wait_for_event(WifiEvent::StaDisconnected).await;
                Timer::after(Duration::from_millis(5000)).await
            }
            _ => {}
        }
        if !matches!(controller.is_started(), Ok(true)) {
            let client_config = ModeConfig::Client(
                ClientConfig::default()
                    .with_ssid(SSID.into())
                    .with_password(PASSWORD.into()),
            );
            controller.set_config(&client_config).unwrap();
            println!("Starting wifi");
            controller.start_async().await.unwrap();
            println!("Wifi started!");

            println!("Scan");
            let scan_config = ScanConfig::default().with_max(10);
            let result = controller
                .scan_with_config_async(scan_config)
                .await
                .unwrap();
            for ap in result {
                println!("{:?}", ap);
            }
        }
        println!("About to connect...");

        match controller.connect_async().await {
            Ok(_) => println!("Wifi connected!"),
            Err(e) => {
                println!("Failed to connect to wifi: {:?}", e);
                Timer::after(Duration::from_millis(5000)).await
            }
        }
    }
}

#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
    runner.run().await
}

async fn access_website(stack: Stack<'_>, tls_seed: u64) {
    let mut rx_buffer = [0; 4096];
    let mut tx_buffer = [0; 4096];
    let dns = DnsSocket::new(stack);
    let tcp_state = TcpClientState::<1, 4096, 4096>::new();
    let tcp = TcpClient::new(stack, &tcp_state);

    let tls = TlsConfig::new(
        tls_seed,
        &mut rx_buffer,
        &mut tx_buffer,
        reqwless::client::TlsVerify::None,
    );

    let mut client = HttpClient::new_with_tls(&tcp, &dns, tls);
    let mut buffer = [0u8; 4096];
    let mut http_req = client
        .request(
            reqwless::request::Method::GET,
            "https://jsonplaceholder.typicode.com/posts/1",
        )
        .await
        .unwrap();
    let response = http_req.send(&mut buffer).await.unwrap();

    info!("Got response");
    let res = response.body().read_to_end().await.unwrap();

    let content = core::str::from_utf8(res).unwrap();
    println!("{}", content);
}

Пишем код на Rust для запуска веб-сайта на ESP32

В предыдущих упражнениях мы обращались к сайтам и выводили ответ в консоль. В этом упражнении сделаем наоборот: запустим веб-сервер на ESP32. Этот сервер будет доступен в локальной сети Wi-Fi. Чтобы открыть его из интернета, потребуется дополнительная настройка. Сначала сосредоточимся на доступе к сайту в локальной сети Wi-Fi. По-прежнему будем работать только в режиме STA (то есть подключаться к существующей сети Wi-Fi).

Что мы будем делать

Настроим простой веб-сервер, который отдаёт одну страницу index.html. В этом примере будем считать, что ESP32 назначен IP-адрес «192.168.0.101» (он выводится в консоль во время работы). Когда сервер запущен, страницу можно открыть в браузере компьютера по адресу «http://192.168.0.101/». Можно использовать свою HTML-страницу или index.html, который я создал для этого упражнения и который можно найти здесь.

Note

Можно задать статический IP-адрес вместо того, чтобы его назначал DHCP-сервер. Так адрес будет постоянным, но добавятся дополнительные шаги. Чтобы не усложнять, в этом упражнении мы этого делать не будем. Настройку статического IP покажем в одном из следующих упражнений.

Хватит ждать, начнём прямо сейчас.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 webserver-html

Откроется экран с вопросом о выборе опций. Чтобы включить Wi-Fi, сначала нужно включить функции «unstable» и «alloc». Как вы могли заметить, пока не выбраны эти две опции, пункт Wi-Fi недоступен. Поэтому выбирайте их по одной:

  • Сначала выберите опцию «Enable unstable HAL features».
  • Выберите опцию «Enable allocations via the esp-alloc crate».
  • Теперь можно включить «Enable Wi-Fi via esp-radio crate».
  • Выберите опцию «Adds embassy framework support».

Также включите логирование.

  • Прокрутите до пункта «Flashing, logging and debugging (espflash)» и нажмите Enter.
  • Затем выберите «Use defmt to print messages».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем зависимости

Крейт picoserve

picoserve — это крейт, который предоставляет асинхронный HTTP-сервер для bare-metal окружений и сильно вдохновлён Axum. Как можно догадаться по названию, изначально он создавался с расчётом на «Raspberry Pi Pico W» и Embassy. Но он прекрасно работает и с другими встраиваемыми рантаймами и железом, включая ESP32. Этот крейт сильно упрощает работу. Без него пришлось бы писать ядро веб-сервера с нуля, а это трудоёмкая задача, выходящая за рамки этой книги.

picoserve = { version = "0.17.1", features = ["embassy"] }

Структура проекта

Разделим логику на модули. В lib создадим два подмодуля: web.rs и wifi.rs.

├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs
│   ├── index.html
│   ├── lib.rs
│   ├── web.rs
│   └── wifi.rs

Модуль lib

Перенесём макрос mk_static, который позволяет создавать статические переменные, инициализируемые во время выполнения, в модуль lib. Кроме того, включим нестабильную возможность языка (feature gate) impl_trait_in_assoc_type, которую требует крейт picoserve.

#![allow(unused)]
#![no_std]
#![feature(impl_trait_in_assoc_type)]

fn main() {
pub mod web;
pub mod wifi;

#[macro_export]
macro_rules! mk_static {
    ($t:ty,$val:expr) => {{
        static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
        #[deny(unused_attributes)]
        let x = STATIC_CELL.uninit().write(($val));
        x
    }};
}
}

Функция main (файл main.rs)

Чтобы использовать модуль lib, обычно пришлось бы обращаться к нему по полному имени проекта (например, webserver::web). Однако чтобы ссылки оставались одинаковыми в разных упражнениях, мы дадим импорту псевдоним «lib». Так к нему можно обращаться как к «lib::web».

В функции main начинаем с типового кода: настраиваем глобальный аллокатор кучи и инициализируем Embassy.

Далее создаём контроллер Wi-Fi, который передаём функции start_wifi; её мы вскоре определим в модуле wifi. Эта функция вернёт экземпляр сетевого стека.

Создадим экземпляр веб-приложения и настроим маршрутизацию и параметры с помощью крейта picoserve. Затем запустим несколько задач для обработки входящих запросов в соответствии с заданным размером пула. Каждая задача получает идентификатор, экземпляр приложения, сетевой стек и настройки сервера.

use webserver_html as lib;

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let radio_init = &*lib::mk_static!(
        esp_radio::Controller<'static>,
        esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller")
    );
    let rng = Rng::new();

    let stack = lib::wifi::start_wifi(radio_init, peripherals.WIFI, rng, &spawner).await;

    let web_app = lib::web::WebApp::default();
    for id in 0..lib::web::WEB_TASK_POOL_SIZE {
        spawner.must_spawn(lib::web::web_task(
            id,
            stack,
            web_app.router,
            web_app.config,
        ));
    }

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Модуль Wi-Fi

Настройка подключения Wi-Fi

Код настройки Wi-Fi такой же, как описан в главе “Подключение ESP32 к Wi-Fi с поддержкой Embassy” (в разделе про доступ к сайту). Чтобы не повторяться, я не буду объяснять его здесь снова. Если вы ещё не читали ту главу, загляните в неё.

#![allow(unused)]
fn main() {
const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");
}

Запуск Wi-Fi

Функция start_wifi отвечает за настройку и запуск подключения Wi-Fi для ESP32. Вот пошаговое объяснение:

  1. Создадим интерфейс и контроллер Wi-Fi в режиме STA (Station), чтобы ESP32 мог подключиться к существующей сети Wi-Fi.

  2. Обычно, когда устройство подключается к сети Wi-Fi, роутер автоматически назначает ему IP-адрес через DHCP-сервер. В этом упражнении настроим ESP32 так, чтобы он запрашивал IP-адрес через DHCP.
    Для этого создадим экземпляр net_config, настроенный на DHCP. Затем используем эту конфигурацию вместе с Wi-Fi-интерфейсом для инициализации сетевого стека и runner.

  3. Запустим две задачи:

    • Задача connection следит за подключением Wi-Fi и переподключает его, если соединение разорвано.
    • Задача network управляет всем сетевым обменом данными.
  4. Подождём, пока соединение Wi-Fi не поднимется. Когда подключение будет готово, выведем IP-адрес, назначенный нашему ESP32.

  5. Наконец, вернём экземпляр сетевого стека. Далее его использует веб-задача для работы веб-сервера.

#![allow(unused)]
fn main() {

pub async fn start_wifi(
    radio_init: &'static esp_radio::Controller<'static>,
    wifi: esp_hal::peripherals::WIFI<'static>,
    rng: Rng,
    spawner: &Spawner,
) -> Stack<'static> {
    let (wifi_controller, interfaces) = esp_radio::wifi::new(radio_init, wifi, Default::default())
        .expect("Failed to initialize Wi-Fi controller");

    let wifi_interface = interfaces.sta;
    let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32);

    let dhcp_config = DhcpConfig::default();
    let net_config = embassy_net::Config::dhcpv4(dhcp_config);

    // Инициализируем сетевой стек
    let (stack, runner) = embassy_net::new(
        wifi_interface,
        net_config,
        mk_static!(StackResources<3>, StackResources::<3>::new()),
        net_seed,
    );

    spawner.spawn(connection(wifi_controller)).ok();
    spawner.spawn(net_task(runner)).ok();

    wait_for_connection(stack).await;

    stack
}

async fn wait_for_connection(stack: Stack<'_>) {
    println!("Waiting for link to be up");
    loop {
        if stack.is_link_up() {
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }

    println!("Waiting to get IP address...");
    loop {
        if let Some(config) = stack.config_v4() {
            println!("Got IP: {}", config.address);
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }
}
}

Задачи Wi-Fi и сети

В логике этих двух задач нет существенных изменений. Единственное отличие: теперь в net_task передаётся экземпляр runner, а раньше этого не было.

#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) {
    println!("start connection task");
    println!("Device capabilities: {:?}", controller.capabilities());
    loop {
        match esp_radio::wifi::sta_state() {
            WifiStaState::Connected => {
                // ждём, пока соединение не будет разорвано
                controller.wait_for_event(WifiEvent::StaDisconnected).await;
                Timer::after(Duration::from_millis(5000)).await
            }
            _ => {}
        }
        if !matches!(controller.is_started(), Ok(true)) {
            let client_config = ModeConfig::Client(
                ClientConfig::default()
                    .with_ssid(SSID.into())
                    .with_password(PASSWORD.into()),
            );
            controller.set_config(&client_config).unwrap();
            println!("Starting wifi");
            controller.start_async().await.unwrap();
            println!("Wifi started!");

            println!("Scan");
            let scan_config = ScanConfig::default().with_max(10);
            let result = controller
                .scan_with_config_async(scan_config)
                .await
                .unwrap();
            for ap in result {
                println!("{:?}", ap);
            }
        }
        println!("About to connect...");

        match controller.connect_async().await {
            Ok(_) => println!("Wifi connected!"),
            Err(e) => {
                println!("Failed to connect to wifi: {:?}", e);
                Timer::after(Duration::from_millis(5000)).await
            }
        }
    }
}
}
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
    runner.run().await
}
}

Веб-модуль: отдача веб-страницы

Типовой код для подключения Wi-Fi готов. Далее с помощью крейта picoserve настроим маршрут для корневого URL («/»), который будет отдавать нашу HTML-страницу.

Нестабильная возможность impl_trait_in_assoc_type

Крейту picoserve нужна возможность языка impl_trait_in_assoc_type, которая в Rust пока нестабильна. Чтобы её включить, добавьте следующую строку в начало файла lib.rs (мы уже сделали это):

#![allow(unused)]
#![feature(impl_trait_in_assoc_type)]
fn main() {
}

Приложение и маршрутизация

Крейт picoserve предоставляет различные трейты для настройки маршрутизации и других функций, нужных веб-приложению. Трейт AppBuilder используется для создания статического роутера без состояния, а трейт AppWithStateBuilder позволяет создать статический роутер с состоянием приложения. Поскольку наше приложение отдаёт только одну HTML-страницу и не требует состояния, реализуем трейт AppBuilder. Больше примеров использования picoserve можно найти здесь.

#![allow(unused)]
fn main() {
pub struct Application;

impl AppBuilder for Application {
    type PathRouter = impl routing::PathRouter;

    fn build_app(self) -> picoserve::Router<Self::PathRouter> {
        picoserve::Router::new().route(
            "/",
            routing::get_service(File::html(include_str!("index.html"))),
        )
    }
}
}

Мы создали простую структуру, которая реализует трейт AppBuilder. Нужно указать тип PathRouter, и мы определяем его как любой тип, реализующий трейт routing::PathRouter.

Затем нужно реализовать функцию build_app, которая возвращает экземпляр Router. Мы настраиваем единственный маршрут для «/», который отдаёт статическую HTML-страницу. Содержимое HTML-страницы встраивается в приложение на этапе компиляции с помощью макроса include_str!(“index.html”). Поместите файл «index.html» в папку «src/».

Размер пула

Чтобы обрабатывать входящие запросы, нужно запустить несколько задач. Вскоре мы создадим функцию web_task (задачу Embassy) с размером пула, который задаётся константой, объявленной прямо сейчас. Затем запустим задачи в цикле на основе этого значения, которое определяет, сколько задач может работать одновременно.

#![allow(unused)]
fn main() {
pub const WEB_TASK_POOL_SIZE: usize = 2;
}

Мы задали размер пула 2. В примерах picoserve размер пула равен 8. Его можно увеличить, но имейте в виду, что тогда придётся соответствующим образом изменить и такие ресурсы, как сокеты и область памяти.

Веб-приложение

Определим структуру WebApp, которая содержит экземпляр роутера picoserve и конфигурацию.

#![allow(unused)]
fn main() {
pub struct WebApp {
    pub router: &'static Router<<Application as AppBuilder>::PathRouter>,
    pub config: &'static picoserve::Config<Duration>,
}
}

Далее реализуем трейт Default для WebApp и инициализируем роутер picoserve, вызвав build_app. Также настроим таймауты сервера, которые ограничивают время выполнения операций: чтения запроса, ожидания чтения или ожидания записи ответа. Если какая-либо операция превышает таймаут, соединение будет закрыто.

#![allow(unused)]
fn main() {
impl Default for WebApp {
    fn default() -> Self {
        let router = picoserve::make_static!(AppRouter<Application>, Application.build_app());

        let config = picoserve::make_static!(
            picoserve::Config<Duration>,
            picoserve::Config::new(picoserve::Timeouts {
                start_read_request: Some(Duration::from_secs(5)),
                read_request: Some(Duration::from_secs(1)),
                write: Some(Duration::from_secs(1)),
                persistent_start_read_request: Some(Duration::from_secs(1)),
            })
            .keep_connection_alive()
        );

        Self { router, config }
    }
}
}

Функция веб-задачи

Мы создали задачу Embassy и указали размер пула в атрибуте. Веб-сервер будет слушать порт 80. Для каждой задачи мы определяем буферы чтения и записи TCP, а также HTTP-буфер. Наконец, вызываем функцию listen_and_serve из picoserve для обработки входящих запросов.

#![allow(unused)]
fn main() {
#[embassy_executor::task(pool_size = WEB_TASK_POOL_SIZE)]
pub async fn web_task(
    task_id: usize,
    stack: Stack<'static>,
    router: &'static AppRouter<Application>,
    config: &'static picoserve::Config<Duration>,
) -> ! {
    let port = 80;
    let mut tcp_rx_buffer = [0; 1024];
    let mut tcp_tx_buffer = [0; 1024];
    let mut http_buffer = [0; 2048];

    picoserve::Server::new(router, config, &mut http_buffer)
        .listen_and_serve(task_id, stack, port, &mut tcp_rx_buffer, &mut tcp_tx_buffer)
        .await
        .into_never()
}
}

Клонирование существующего проекта

Можно клонировать проект, который я создал (или просто посмотреть его), и перейти в папку webserver-html.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/webserver-html

Как запустить?

В этом примере тоже нужно передать переменные окружения для подключения к Wi-Fi. Можно создать файл .env или передать их напрямую, как делаю я здесь.

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD  cargo run --release

Когда прошивка будет загружена в ESP32, в консоли должен появиться такой вывод с IP-адресом, который назначил ваш Wi-Fi-роутер.

Убедитесь, что ваш компьютер подключён к той же сети Wi-Fi. Затем откройте веб-страницу в браузере по адресу «http://192.168.0.101/» (замените на полученный IP-адрес).

Открываем веб-сервер ESP32 в интернет

Обычно не рекомендуется открывать из интернета IoT-веб-серверы, например те, что работают на ESP32, без должных мер безопасности. Такие устройства могут быть уязвимы для атак, например для несанкционированного доступа, включения в ботнет или других видов ущерба, в зависимости от устройства (на вашем месте я бы не стал рисковать). Кроме того, использовать ESP32 как веб-хостинг непрактично из-за таких сложностей, как открытие портов, обработка нагрузки трафика и потенциальные риски безопасности. Тем не менее это может быть отличным учебным опытом, если хотите попробовать или показать друзьям.

Проброс портов на роутере

Один из вариантов — настроить проброс портов на роутере, сопоставив внешний IP-адрес и порт (например, публичный IP 202.xx.xx.10) с внутренним IP-адресом и портом ESP32 (например, 192.168.56.101). Однако в большинстве домашних сетей используются динамические публичные IP-адреса, которые выдаёт провайдер, и они могут со временем меняться. Из-за этого настройка может быть ненадёжной и в некоторых случаях не работать. Чтобы обеспечить стабильный доступ, может понадобиться статический публичный IP-адрес. Обычно для этого нужно обратиться к провайдеру, и это может стоить дополнительных денег.

Можно также использовать такие сервисы, как No-IP, вместе с пробросом портов и динамическим IP-адресом. Если ваш роутер это поддерживает, DDNS или No-IP можно настроить прямо в его настройках. Подробнее см. на официальном сайте No-IP здесь. Однако имейте в виду, что такой подход может не работать с некоторыми провайдерами, которые блокируют открытые порты на динамических IP-адресах.

Туннелирование

При туннелировании статический IP-адрес не нужен, но понадобится внешний сервис, который предоставляет туннели. Чтобы это работало, нужно установить клиент на ваш ПК или Raspberry Pi (не на Pico) и запустить его для создания туннеля (знаю, это не очень весело). Запустить этот клиент на ESP32 нельзя. Когда туннель будет настроен, сервер можно будет открыть по URL, который выдаст провайдер.

Можно использовать такие сервисы, как ngrok или Cloudflare Tunnel. Чтобы начать работу с ngrok, посмотрите официальное руководство здесь. Официальное руководство по Cloudflare Tunnel доступно здесь.

Пример с ngrok

Например, при использовании ngrok я сначала зарегистрировался на их портале и получил токен аутентификации. Затем запустил клиент на компьютере и добавил токен командой ngrok config add-authtoken MY_AUTH_TOKEN.

Чтобы создать туннель, я выполнил команду: ngrok http 192.168.0.101:80. Здесь 192.168.0.101 — это IP-адрес ESP32. Клиент создал публичный URL в домене ngrok. Я смог открыть этот URL из другой сети (например, сети телефона), чтобы убедиться, что туннель работает правильно.

Статический IP-адрес

В предыдущих упражнениях мы полагались на DHCP-сервер, который назначал ESP32 IP-адрес. Однако это может быть ненадёжно, так как IP-адрес со временем может измениться. Во многих случаях нужно назначить статический IP-адрес, чтобы у ESP32 всегда был фиксированный, предсказуемый адрес. Так к устройству проще обращаться постоянно, не проверяя и не обновляя его IP-адрес при каждом переподключении к сети.

Теперь удалим код, связанный с DHCP, и изменим переменную net_config так, чтобы она инициализировалась статическим IP-адресом.

Сначала определим константы, которые будут загружать статический IP-адрес и IP-адрес шлюза из переменных окружения. IP-адрес должен быть в формате CIDR, который включает и сам IP-адрес, и маску подсети. Нужно указать IP-адрес, затем косую черту и маску подсети. Например, если хотите назначить ESP32 адрес 192.168.0.50, запишите его как 192.168.0.50/24.

Tip

Назначить можно не любой IP-адрес. Нужно узнать диапазон адресов, который использует ваш Wi-Fi-роутер. Для этого введите в терминале ip a и найдите IP-адрес рядом с Wi-Fi-интерфейсом (обычно он начинается с wl). Например, если IP-адрес вашей системы 192.168.0.103, можно назначить адрес, начиная с 192.168.0.2

Базовый проект

Скопируйте тот же проект, который мы создали в предыдущей главе.

cp -r webserver-html webserver-base
#![allow(unused)]
fn main() {
// IP-адрес/маска подсети, например: STATIC_IP=192.168.0.50/24
const STATIC_IP: &str = env!("STATIC_IP");
const GATEWAY_IP: &str = env!("GATEWAY_IP");
}

Шлюз тоже нужно настроить. Для этого упражнения он не обязателен, так как мы не будем отправлять запросы в интернет, но для будущих упражнений полезно настроить его заранее.

Адрес шлюза часто является первым адресом в диапазоне IP-адресов вашей Wi-Fi-сети. Например, если адреса находятся в диапазоне от 192.168.0.1 до 192.168.0.255, шлюз, скорее всего, будет 192.168.0.1. Узнать адрес шлюза в Linux можно также командой ip route | grep default.

В модуле wifi.rs, который мы создали в предыдущей главе, удалите конфигурацию DHCP и замените её в функции start_wifi следующим кодом:

#![allow(unused)]
fn main() {
// Дополнительные импорты
use core::str::FromStr;
use core::net::Ipv4Addr;
use embassy_net::Ipv4Cidr;
}
#![allow(unused)]
fn main() {
//найдите фрагмент `let net_config` и замените его
let Ok(ip_addr) = Ipv4Cidr::from_str(STATIC_IP) else {
    println!("Invalid STATIC_IP");
    loop {}
};

let Ok(gateway) = Ipv4Addr::from_str(GATEWAY_IP) else {
    println!("Invalid GATEWAY_IP");
    loop {}
};

let net_config = embassy_net::Config::ipv4_static(StaticConfigV4 {
    address: ip_addr,
    gateway: Some(gateway),
    dns_servers: Vec::new(),
});
// В вызове `embassy_net::new` ничего менять не нужно.
}

Обновлённая функция start_wifi

#![allow(unused)]
fn main() {
pub async fn start_wifi(
    radio_init: &'static esp_radio::Controller<'static>,
    wifi: esp_hal::peripherals::WIFI<'static>,
    rng: Rng,
    spawner: &Spawner,
) -> Stack<'static> {
    let (wifi_controller, interfaces) = esp_radio::wifi::new(radio_init, wifi, Default::default())
        .expect("Failed to initialize Wi-Fi controller");

    let wifi_interface = interfaces.sta;
    let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32);

    let Ok(ip_addr) = Ipv4Cidr::from_str(STATIC_IP) else {
        println!("Invalid STATIC_IP");
        loop {}
    };

    let Ok(gateway) = Ipv4Addr::from_str(GATEWAY_IP) else {
        println!("Invalid GATEWAY_IP");
        loop {}
    };

    let net_config = embassy_net::Config::ipv4_static(StaticConfigV4 {
        address: ip_addr,
        gateway: Some(gateway),
        dns_servers: Default::default(),
    });
    // Инициализируем сетевой стек
    let (stack, runner) = embassy_net::new(
        wifi_interface,
        net_config,
        mk_static!(StackResources<3>, StackResources::<3>::new()),
        net_seed,
    );

    spawner.spawn(connection(wifi_controller)).ok();
    spawner.spawn(net_task(runner)).ok();

    wait_for_connection(stack).await;

    stack
}
}

Базовый проект

Я реорганизовал проект, разбив его на модули, например wifi и web, чтобы основной файл оставался аккуратным. Этот проект станет основой для следующего упражнения, поэтому советую посмотреть, как он устроен.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/webserver-base

Структура проекта:

├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs
│   ├── index.html
│   ├── lib.rs
│   ├── web.rs
│   └── wifi.rs

Как запустить?

Обычно мы просто запускаем cargo run --release, но на этот раз нужно также передать переменные окружения для подключения к Wi-Fi.

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD STATIC_IP=ASSIGN_ESP32_IP/24 GATEWAY_IP=WIFI_GATEWAY_IP  cargo run --release

Пишем код на Rust для управления светодиодом ESP32 по Wi-Fi

Веб-сервер на ESP32 может получать команды через веб-запросы, что позволяет управлять подключёнными устройствами или выполнять определённые действия. Например, с помощью браузера на компьютере или телефоне можно отправлять команды: включить или выключить светодиод, изменить скорость мотора или получить данные с датчика.

В этом разделе мы создадим простую веб-страницу, с помощью которой можно включать и выключать светодиод.

Базовый проект

На этот раз проект с помощью esp-generate создавать не будем. Вместо этого скопируем проект webserver-base и будем работать на его основе.

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

git clone https://github.com/ImplFerris/esp32-projects
cp -r esp32-projects/webserver-base ~/YOUR_PROJECT_FOLDER/wifi-led

Структура проекта:

├── build.rs
├── Cargo.toml
├── rust-toolchain.toml
├── src
│   ├── bin
│   │   └── main.rs
│   ├── index.html
│   ├── led.rs
│   ├── lib.rs
│   ├── web.rs
│   └── wifi.rs

Serde

Serde — это крейт Rust для сериализации и десериализации структур данных. Мы будем использовать его для обработки JSON-данных, которыми обмениваются бэкенд и фронтенд.

Обновите Cargo.toml следующим образом:

serde = { version = "1.0.228", default-features = false, features = ["derive"] }

# Включаем feature «json»
picoserve = { version = "0.17.1", features = ["embassy", "json"] }

Задача для светодиода

Этот код я разместил в модуле «led.rs».

Сначала создадим задачу Embassy, которая переключает состояние встроенного светодиода в зависимости от значения переменной LED_STATE, имеющей тип AtomicBool. «Атомарные типы обеспечивают примитивный обмен данными между потоками через общую память и являются основой для других конкурентных типов». Чтобы узнать больше об атомарных типах, обратитесь к документации стандартной библиотеки Rust по atomics или к книге Rust Atomics and Locks.

#![allow(unused)]
fn main() {
use core::sync::atomic::{AtomicBool, Ordering};

use embassy_time::{Duration, Timer};
use esp_hal::gpio::Output;

pub static LED_STATE: AtomicBool = AtomicBool::new(false);

#[embassy_executor::task]
pub async fn led_task(mut led: Output<'static>) {
    loop {
        if LED_STATE.load(Ordering::Relaxed) {
            led.set_high();
        } else {
            led.set_low();
        }
        Timer::after(Duration::from_millis(50)).await;
    }
}
}

В функции led_task мы принимаем вывод светодиода как аргумент и в цикле постоянно проверяем значение переменной LED_STATE. Значение читаем методом load с Ordering::Relaxed. Если значение true, включаем светодиод, иначе выключаем его.

В функции main мы запускаем led_task в фоновом режиме. Передаём вывод GPIO 2 (если хотите использовать внешний светодиод, замените его на вывод, к которому подключили светодиод), то есть встроенный светодиод, и задаём начальное состояние светодиода Low.

#![allow(unused)]
fn main() {
// Задача для светодиода
let led = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
spawner.must_spawn(lib::led::led_task(led));
}

Управление светодиодом ESP32 через веб-страницу

Мы создадим простую конечную точку API, которая принимает логическое значение и управляет состоянием светодиода. Кроме того, будет отдаваться страница «index.html» с двумя кнопками: одна включает светодиод, другая выключает его.

При нажатии одной из кнопок на конечную точку «/led» будет отправлен запрос с таким JSON-телом:

  • { "is_on": true } — включить светодиод
  • { "is_on": false } — выключить светодиод

В зависимости от значения поля «is_on» обновляется переменная «LED_STATE» модуля led. Затем «led_task» соответственно включает или выключает светодиод.

Маршрутизация

В функции «build_app» настраиваются веб-маршруты приложения. Корневой путь («/») отдаёт содержимое «index.html», поэтому этот файл нужно поместить в папку «src/». Путь «/led» принимает POST-запросы, которые обрабатывает «led_handler».

#![allow(unused)]
fn main() {
pub struct Application;

impl AppBuilder for Application {
    type PathRouter = impl routing::PathRouter;

    fn build_app(self) -> picoserve::Router<Self::PathRouter> {
        picoserve::Router::new()
            .route(
                "/",
                routing::get_service(File::html(include_str!("index.html"))),
            )
            .route("/led", routing::post(led_handler))
    }
}
}

Обработчик светодиода

Мы определим две структуры: одну для входящих данных и одну для ответа. Структура LedRequest получит derive Deserialize, чтобы разбирать входящий JSON и представлять его в виде экземпляра структуры. Структура LedResponse получит derive Serialize, чтобы преобразовывать экземпляр структуры и отправлять его как JSON-ответ.

#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct LedRequest {
    is_on: bool,
}

#[derive(serde::Serialize)]
struct LedResponse {
    success: bool,
}
}

В функции led_handler структура LedRequest извлекается как параметр. Значение «is_on» можно сразу сохранить в LED_STATE, так как оба значения логические. Наконец, обработчик возвращает JSON-ответ с LedResponse, в котором указывается успешное выполнение.

#![allow(unused)]
fn main() {
async fn led_handler(input: picoserve::extract::Json<LedRequest>) -> impl IntoResponse {
    crate::led::LED_STATE.store(input.0.is_on, Ordering::Relaxed);

    picoserve::response::Json(LedResponse { success: true })
}
}

Содержимое веб-страницы

Файл index.html можно скачать здесь и поместить в папку «src/», либо создать собственное содержимое, которое отправляет JSON-запросы.

Примечание:

Нужно заменить URL «http://192.168.0.50/led» на IP-адрес вашего ESP32. Для простоты я прописал адрес прямо в коде; иначе пришлось бы использовать заглушку, которую заменяли бы динамически, или подход на основе шаблонов.

<div class="button-container">
        <button class="btn-on" onclick="sendRequest(true)">Turn on LED</button>
        <button class="btn-off" onclick="sendRequest(false)">Turn off LED</button>
    </div>

    <script>
        function sendRequest(is_on) {
            const url = 'http://192.168.0.50/led'; // Замените на статический IP-адрес ESP32

            fetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ is_on })
            })
            .then(response => {
                if (response.ok) {
                    return response.json();
                }
                throw new Error('Network response was not ok');
            })
            .then(data => {
                console.log('Success:', data);
                //alert(LED turned ${action});
            })
            .catch(error => {
                console.error('Error:', error);
                alert('Failed to send the request');
            });
        }
    </script>

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку wifi-led.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/wifi-led

Как запустить?

Передайте имя Wi-Fi, пароль, статический IP-адрес и IP-адрес шлюза как переменные окружения, а затем прошейте ESP32

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD STATIC_IP=ASSIGN_ESP32_IP/24 GATEWAY_IP=WIFI_GATEWAY_IP  cargo run --release

Точка доступа: создание Wi-Fi-сети на ESP32

До сих пор мы использовали существующую Wi-Fi-сеть. Однако с помощью ESP32 можно создать собственную Wi-Fi-сеть (только не ждите, что она даст доступ к интернету). В этом упражнении мы настроим ESP32 как точку доступа и запустим веб-сервер.

Базовый проект

Снова скопируем проект webserver-base и будем работать на его основе.

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

git clone https://github.com/ImplFerris/esp32-projects
cp -r esp32-projects/webserver-base ~/YOUR_PROJECT_FOLDER/wifi-led

Настройка Wi-Fi ESP32 (в файле wifi.rs)

Чтобы создать собственную Wi-Fi-сеть на ESP32, нужно задать статический IP-адрес в нотации CIDR (например, 192.168.2.1/24) и указать IP-адрес шлюза (например, 192.168.2.1). Также нужно задать имя Wi-Fi (SSID), которое может быть любым, но не длиннее 32 символов. Пароль для сети необязателен, но мы его настроим. SSID и пароль будут загружаться из переменных окружения, так же как мы делали в режиме станции.

#![allow(unused)]
fn main() {
// В отличие от режима станции, можно задать любой частный диапазон IP-адресов
// IP-адрес/маска подсети, например: STATIC_IP=192.168.13.37/24
const STATIC_IP: &str = "192.168.13.37/24";
// IP-адрес шлюза, например: GATEWAY_IP="192.168.13.37"
const GATEWAY_IP: &str = "192.168.13.37";

const PASSWORD: &str = env!("PASSWORD");
const SSID: &str = env!("SSID");
}

Обновляем задачу подключения

Главная задача этой функции — убедиться, что Wi-Fi-сеть работает. Если она не работает, её нужно перезапустить. Функция в цикле проверяет состояние Wi-Fi. Если состояние WifiApState::Started, она ждёт, пока Wi-Fi не остановится (то есть пока не произойдёт событие ApStop). Если это случилось, выполнение переходит ко второй части цикла.

Если Wi-Fi не запущен, мы настроим его с SSID, необязательным паролем и режимом аутентификации WPA2 Personal. Затем запустим Wi-Fi-сеть.

Примечание:

Чтобы запустить Wi-Fi без пароля, закомментируйте вызовы password и auth_method в AccessPointConfig. Тогда сеть будет открытой и будет использовать конфигурацию по умолчанию.

#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) {
    println!("start connection task");
    println!("Device capabilities: {:?}", controller.capabilities());
    loop {
        match esp_radio::wifi::ap_state() {
            WifiApState::Started => {
                // ждём, пока соединение не будет разорвано
                controller.wait_for_event(WifiEvent::ApStop).await;
                Timer::after(Duration::from_millis(5000)).await
            }
            _ => {}
        }

        if !matches!(controller.is_started(), Ok(true)) {
            let client_config = ModeConfig::AccessPoint(
                AccessPointConfig::default()
                    .with_ssid(SSID.into())
                    .with_password(PASSWORD.into())
                    .with_auth_method(esp_radio::wifi::AuthMethod::Wpa2Personal),
            );
            controller.set_config(&client_config).unwrap();
            println!("Starting wifi");
            controller.start_async().await.unwrap();
            println!("Wifi started!");
        }
    }
}
}

Обновляем функцию start_wifi

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

#![allow(unused)]
fn main() {
let wifi_interface = interfaces.ap;
}

Как запустить?

Нужно передать переменные окружения для подключения к Wi-Fi.

Примечание: на этот раз нужно указать имя Wi-Fi и пароль для сети, которую мы создадим с помощью ESP32. Имя может быть любым, но не длиннее 32 символов. Пароль можно передать или пропустить, если вы закомментировали часть кода с паролем.

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD  cargo run --release

Когда прошивка будет загружена в ESP32, дождитесь, пока программа выведет IP-адрес и сообщение о запуске веб-сервера.

Подключите компьютер к Wi-Fi ESP32

На нашем ESP32 не работает DHCP-сервер, поэтому устройства, которые подключаются к нашей Wi-Fi-сети, не получают IP-адрес автоматически. Поэтому при подключении нужно задать статический IP-адрес на самом устройстве. Как задать статический IP-адрес для Wi-Fi в вашей операционной системе, можно узнать в её документации и настроить соответствующим образом.

Убедитесь, что ваш компьютер подключён к созданной нами Wi-Fi-сети. Затем откройте веб-страницу в браузере по адресу «http://192.168.13.37/» (замените его на IP-адрес, который вы назначили).

Подключите телефон к Wi-Fi ESP32

Чтобы подключить Android-телефон к Wi-Fi ESP32, выберите эту сеть и нажмите «Подробнее» (на разных моделях телефонов это может выглядеть по-разному), задайте статический IP-адрес и затем переподключитесь.

На Android может появиться сообщение вроде «Интернет может быть недоступен». В этом случае выберите вариант «Подключиться только в этот раз» или «Всегда подключаться». После подключения откройте веб-сервер, перейдя по адресу.

Inter-Integrated Circuit (I2C)

В этом разделе мы узнаем, что такое I2C, и научимся использовать шины I2C ESP32.

Что такое I2C?

I2C расшифровывается как Inter-Integrated Circuit (межмикросхемная шина), также пишется I²C. Это один из популярных способов обмена данными, которые микроконтроллеры используют для связи с датчиками, дисплеями (например, OLED) и другими микросхемами. Это последовательный, полудуплексный и синхронный интерфейс. Разберём, что это значит.

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

  • Полудуплексный (half-duplex) означает, что данные идут только в одном направлении за раз. Представьте рацию: говорит только один человек, пока другой слушает, а потом они меняются ролями.

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

Контроллер и целевое устройство

I2C использует модель «контроллер — целевое устройство». Контроллер (раньше его называли master, «ведущий») — это устройство, которое инициирует обмен и формирует сигнал тактирования. Целевое устройство (раньше его называли slave, «ведомый») отвечает на команды контроллера.

I2C: один контроллер и одно целевое устройство

Рисунок: один контроллер и одно целевое устройство

В типичных встраиваемых проектах микроконтроллер (например, ESP32) выступает в роли контроллера, а подключённые устройства, такие как дисплеи (например, OLED) или датчики, — в роли целевых устройств.

I2C позволяет легко подключить много устройств к одной паре проводов. К одному контроллеру можно подключить несколько целевых устройств — это самая распространённая схема. I2C также поддерживает несколько контроллеров на одной шине, так что более одного контроллера может обмениваться данными с одним или несколькими целевыми устройствами.

Шина I2C

Шина I2C использует всего две линии, общие для всех подключённых устройств:

  • SCL (Serial Clock Line, линия тактирования): передаёт сигнал тактирования от контроллера. Иногда устройства обозначают её как SCK.

  • SDA (Serial Data Line, линия данных): передаёт данные в обоих направлениях. Иногда устройства обозначают её как SDI.

I2C: один контроллер и несколько целевых устройств

Рисунок: один контроллер и несколько целевых устройств

Все подключённые устройства используют одни и те же два провода. Контроллер выбирает, с каким целевым устройством общаться, отправляя его уникальный адрес.

Адреса I2C

У каждого целевого устройства I2C есть 7-битный или 10-битный адрес. Чаще всего используется 7-битный, который даёт до 128 возможных адресов.

Многие устройства имеют фиксированный адрес, заданный производителем, но другие позволяют настраивать младшие биты адреса с помощью выводов или перемычек. Например, датчик может использовать выводы A0 и A1, чтобы менять адрес, и тогда на одной шине можно использовать несколько одинаковых микросхем.

Когда контроллер хочет связаться с целевым устройством, он начинает с условия START, затем отправляет адрес устройства и бит чтения/записи. Соответствующее устройство отвечает сигналом ACK (подтверждение), и обмен продолжается.

Режимы скорости

I2C поддерживает разные режимы скорости в зависимости от того, насколько быстро нужно передавать данные. Стандартный режим работает до 100 кбит/с, быстрый режим (fast mode) — до 400 кбит/с, а Fast Mode Plus позволяет до 1 Мбит/с. Для ещё более быстрой связи режим High-Speed поддерживает до 3,4 Мбит/с. Есть и сверхбыстрый режим Ultra-Fast (5 Мбит/с). Скорость, которую можно использовать, зависит от того, какие режимы одновременно поддерживаются интерфейсом I2C микроконтроллера и подключёнными целевыми устройствами.

Зачем I2C?

I2C идеально подходит, когда нужно подключить несколько устройств всего двумя проводами. Он хорошо подходит для задач, где скорость не критична, а простота проводки важна.

Хорошая новость: во встраиваемом Rust не нужно реализовывать протокол I2C самостоятельно. Крейт embedded-hal определяет общие трейты I2C, а HAL для вашего чипа берёт на себя низкоуровневые детали. В следующем разделе мы разберём это подробнее.

Ресурсы

Использование I2C в экосистеме embedded Rust

В предыдущем разделе мы изучили основы обмена по I2C и то, как работает модель «контроллер — целевое устройство» (ведущий — ведомый). Теперь посмотрим, как эти понятия применяются в экосистеме embedded Rust, где ключевой принцип — модульный и переиспользуемый дизайн.

Роль embedded-hal

Крейт embedded-hal определяет стандартный набор трейтов для абстракции аппаратного обеспечения, в том числе для I2C. Эти трейты позволяют писать драйверы (например, для дисплеев или датчиков) в обобщённом виде, чтобы они могли работать на многих микроконтроллерах без изменений, специфичных для платформы.

Основной трейт I2C выглядит так:

#![allow(unused)]
fn main() {
pub trait I2c<A: AddressMode = SevenBitAddress>: ErrorType {
    // Этот метод должен реализовать автор HAL
    fn transaction(...);
    // Это методы по умолчанию, построенные поверх `transaction`
    fn read(...);
    fn write(...);
    fn write_read(...);
}
}

Единственный метод, который HAL обязан реализовать, — transaction. Трейт предоставляет реализации по умолчанию для read, write и write_read, которые используют этот метод.

Обобщённый параметр типа A задаёт режим адресации и по умолчанию равен SevenBitAddress. Поэтому в большинстве случаев его не нужно указывать вручную. Для 10-битной адресации можно использовать TenBitAddress.

Крейты HAL для конкретных микроконтроллеров (например, esp-hal, stm32-hal или nrf-hal) реализуют этот трейт для своей периферии I2C. Например, крейт esp-hal реализует I2C. Если интересно, можете посмотреть реализацию здесь.

Помимо обычного крейта embedded-hal, есть асинхронная версия под названием embedded-hal-async. Она определяет похожие трейты, но они рассчитаны на работу с асинхронным кодом, что полезно при написании неблокирующих драйверов или задач во встраиваемых системах.

Драйверы, не зависящие от платформы

Представьте, что вы пишете драйвер для датчика или дисплея, который работает по I2C. Вы не хотите привязывать свой код к конкретному микроконтроллеру, например Raspberry Pi Pico или ESP32. Вместо этого можно написать драйвер обобщённо, с помощью трейта embedded-hal.

Пока ваш драйвер зависит только от трейта I2C, он может работать на любой платформе, которая реализует этот трейт, — будь то STM32, nRF или ESP32.

Совместное использование шины I2C

Во многих встраиваемых проектах к одним и тем же линиям SDA и SCL подключено несколько устройств I2C (например, OLED-дисплей, LCD и различные датчики). Однако управлять шиной может только одно устройство за раз.

I2C: микроконтроллер ESP32 и несколько устройств

Рисунок: микроконтроллер (ESP32) и несколько устройств

Если дать исключительный доступ одному драйверу, другие устройства не смогут обмениваться данными. Здесь помогает крейт embedded-hal-bus.

Он предоставляет обёртки, такие как AtomicDevice, CriticalSectionDevice и RefCellDevice, которые позволяют нескольким драйверам безопасно разделять доступ к одной шине I2C. Сами эти обёртки реализуют трейт I2c, поэтому драйверы могут использовать их так же, как исходную шину.

Использовать I2C можно двумя способами:

I2C: один контроллер и несколько устройств
  • Без совместного использования: если ваше приложение общается только с одним устройством I2C, можно передать драйверу экземпляр шины I2C от HAL (который реализует трейт I2c) напрямую.

  • С совместным использованием: если приложению нужно обмениваться данными с несколькими устройствами I2C на одной шине, можно обернуть экземпляр шины (предоставленный HAL) одним из типов совместного использования из крейта embedded-hal-bus, например AtomicDevice или CriticalSectionDevice. Это обеспечивает безопасный, согласованный доступ для нескольких драйверов.

Ресурсы

  • Документация embedded-hal по I2C: подробное описание того, как устроены трейты I2C и как их предполагается использовать на разных платформах.

Шины I2C ESP32

ESP32 содержит два интерфейса шины I2C, каждый из которых можно настроить как контроллер или как целевое устройство. По даташиту ESP32 один интерфейс относится к основной системе, а другой — к низкопотребляющей подсистеме.

Оба интерфейса поддерживают стандартный режим (100 Кбит/с) и быстрый режим (400 Кбит/с), а также могут работать на скоростях до 5 МГц, хотя реальная производительность зависит от мощности подтягивающих резисторов на линиях SDA и SCL. ESP32 поддерживает 7-битную и 10-битную адресацию, включая режим двух адресов (dual address mode).

Благодаря гибкой матрице GPIO сигналы SDA и SCL можно назначить почти на любые выводы GPIO.

Использование I2C в esp-hal

Чтобы использовать I2C с крейтом esp-hal, мы настраиваем интерфейс и назначаем выводы вручную. Вот пример настройки I2C с GPIO18 как SCL и GPIO23 как SDA:

#![allow(unused)]
fn main() {
 let i2c_bus = esp_hal::i2c::master::I2c::new(
    peripherals.I2C0,
    esp_hal::i2c::master::Config::default().with_frequency(Rate::from_khz(400)),
)
.unwrap()
.with_scl(peripherals.GPIO18)
.with_sda(peripherals.GPIO23);
}

Когда шина готова, её можно передать драйверу, например драйверу для OLED-дисплея:

#![allow(unused)]
fn main() {
let interface = I2CDisplayInterface::new(i2c_bus);
// инициализируем дисплей
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
    .into_buffered_graphics_mode();
}

OLED-дисплей с ESP32

До сих пор мы выводили результаты в системную консоль. Для отладки это нормально, но для реальных задач не очень удобно. Если вы считываете данные датчиков, например температуру, или хотите показывать сообщения пользователю (или себе), нужен дисплей. Обычно для этого используют LCD или OLED.

OLED-дисплеи потребляют меньше энергии и работают лучше, чем LCD, поскольку не требуют подсветки. Они обеспечивают высокий контраст и лучшее качество изображения по сравнению с LCD. В этом разделе мы научимся использовать OLED-модуль с ESP32.

Знакомство с железом

OLED (Organic Light-Emitting Diode, органический светодиод) — популярный модуль дисплея. Такие дисплеи бывают разных размеров и могут поддерживать разные цвета. Они обмениваются данными по протоколу I²C или SPI.

В этом упражнении мы будем использовать монохромный OLED-модуль 0,96 дюйма с разрешением 128 x 64. Он работает от 3,3 В. Для связи используем протокол I2C.

Примечание: чаще всего OLED-дисплеи поставляются со штыревыми разъёмами, которые не припаяны. Пайка — полезный навык, но для неё нужны аккуратность и подготовка. Прежде чем браться за неё, посмотрите побольше видеоуроков и изучите вопрос. Поначалу это может быть непросто, но с практикой становится легче. Если вы пока не чувствуете себя уверенно с паяльником, поищите уже припаянную версию дисплея, хотя она может стоить немного дороже.

SSD1306

SSD1306 — интегральный контроллер, на котором работают многие небольшие OLED-дисплеи, в том числе модуль, который мы будем использовать (0,96 дюйма, 128x64). Этот контроллер управляет обменом данными между ESP32 и OLED-панелью, позволяя дисплею показывать текст, графику и не только.

Даташит: даташит SSD1306 можно найти здесь.

Как работает OLED-модуль?

Мы не будем углубляться в детали технологии OLED, а сосредоточимся на том, что важно для наших упражнений. Разрешение модуля 128x64, то есть всего 128 × 64 = 8192 пикселя. Каждый пиксель можно включать и выключать независимо.

В даташите 128 столбцов называются сегментами (segments), а 64 строки — общими линиями (commons). Будьте внимательны и не путайте «commons» с «columns» (столбцы): слова похожи по написанию.

Память

Пиксели OLED-дисплея организованы по страничной структуре внутри GDDRAM (Graphics Display DRAM). GDDRAM разделена на 8 страниц (от страницы 0 до страницы 7), каждая из которых состоит из 128 столбцов (сегментов) и 8 строк (commons).

(Это изображение взято из даташита)

Сегмент — это 8 бит данных (один байт), и каждый бит представляет один пиксель. При записи данных записывается целый сегмент, то есть весь байт записывается сразу.

(Это изображение взято из даташита)

Сегменты и общие линии можно переназначать программно, что даёт механическую гибкость. Подробнее см. на 25-й странице даташита SSD1306.

Страницы и сегменты

Я создал изображение, которое показывает, как 128x64 пикселя делятся на 8 страниц. Затем я сосредоточился на одной странице, которая содержит 128 сегментов (столбцов) и 8 строк. Наконец, я увеличил один сегмент, чтобы показать, что он представляет собой 8 пикселей, расположенных друг над другом, и каждый пиксель соответствует одному биту.

Библиотека

Не переживайте, если эти понятия пока неясны: их можно изучить позже. Эти детали важнее, если вы собираетесь писать собственный драйвер для SSD1306 или браться за более сложные задачи. А пока у нас есть отличный крейт на Rust, который берёт эти вопросы на себя и упрощает работу.

Схема подключения

Для обмена данными между ESP32 и OLED-дисплеем мы будем использовать I2C. Для I2C нужно настроить два вывода GPIO: SDA (Serial Data, данные) и SCL (Serial Clock, тактирование). На ESP32 для I2C можно использовать любые выводы GPIO. Мы назначим GPIO 18 как SCL, а GPIO 23 как SDA. Вывод VCC OLED подключим к выводу 3,3 В ESP32, а вывод земли (GND) — к земле ESP32.

Вывод ESP32 Провод Вывод OLED Примечания
3.3V
VCC Питает OLED-дисплей.
GND
Земля Подключается к выводу земли OLED.
GPIO 18
SCL Подключает сигнал тактирования (SCL) для связи I2C.
GPIO 23
SDA Подключает сигнал данных (SDA) для связи I2C.

Крейты

Для управления OLED-дисплеем мы будем использовать в основном два крейта: ssd1306 и embedded_graphics.

Драйвер OLED-дисплея SSD1306

Этот крейт предоставляет интерфейс драйвера для монохромных OLED-дисплеев SSD1306, поддерживая I2C и SPI через крейт display_interface. Также есть асинхронная поддержка, которую нужно включить через feature-флаг.

Добавление ssd1306 с поддержкой async

ssd1306 = { version = "0.10.0", features = ["async"] }

Добавление ssd1306 без поддержки async

ssd1306 = { version = "0.10.0", features = [] }

Embedded Graphics

Чтобы выводить текст или рисовать изображения на OLED-дисплее, мы будем использовать крейт embedded_graphics вместе с крейтом ssd1306.

«Embedded-graphics — это библиотека 2D-графики, ориентированная на встраиваемые устройства с ограниченным объёмом памяти».

«Основная цель embedded-graphics — рисовать графику без использования буферов: крейт совместим с no_std и работает без динамического выделения памяти и без предварительного выделения больших блоков памяти. Для этого используется подход на основе итераторов, при котором цвета и позиции пикселей вычисляются на лету с минимумом сохраняемого состояния. Это позволяет приложению использовать гораздо меньше RAM почти без потери производительности».

Этот крейт можно использовать с разными OLED-дисплеями и драйверами в зависимости от типа OLED-модуля. Документация подробно описывает возможности и поддерживаемые драйверы. Рекомендую её изучить.

embedded-graphics = "0.8.1"

Выводим «Hello, Rust!» на OLED с ESP32

Это упражнение — простое знакомство с OLED-дисплеем, поэтому оставим всё просто: выведем «Hello, Rust!» на экран OLED.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 hello-oled

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features»
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

ssd1306 = { version = "0.10.0", features = ["async"] }
embedded-graphics = "0.8.1"

Импорты

Сначала добавим необходимые импорты:

#![allow(unused)]
fn main() {
// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Embedded Graphics
use embedded_graphics::{
    mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
    pixelcolor::BinaryColor,
    prelude::Point,
    prelude::*,
    text::{Baseline, Text},
};
}

Инициализация I2C

Инициализируем интерфейс I2C для связи между ESP32 и OLED-дисплеем. Шина I2C настроена на частоту 400 кГц и тайм-аут в 100 тактов шины. Мы назначаем GPIO18 на SCL (Serial Clock Line, линия тактирования), а GPIO23 — на SDA (Serial Data Line, линия данных) для связи I2C, и включаем асинхронную работу интерфейса.

#![allow(unused)]
fn main() {
let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();
}

Инициализация драйвера ssd1306

Затем мы используем вспомогательную структуру I2CDisplayInterface, чтобы создать предварительно настроенный интерфейс I2C для дисплея. Далее используем структуру Ssd1306Async (для синхронного варианта — Ssd1306), передаём созданный интерфейс, размер дисплея DisplaySize128x64 и поворот дисплея. Так как поворот нам не нужен, задаём DisplayRotation::Rotate0.

Крейт ssd1306 поддерживает три режима работы дисплея:

  • BasicMode, который даёт базовое управление с помощью низкоуровневых методов;
  • BufferedGraphicsMode, который использует буфер кадра для расширенного рисования и интегрируется с embedded-graphics;
  • и TerminalMode — режим без буфера, предназначенный для вывода текста и установки позиции курсора, как в терминале.

В этом упражнении мы будем использовать BufferedGraphicsMode.

Затем вызываем функцию init(), которая инициализирует дисплей в графическом режиме и очищает его.

#![allow(unused)]
fn main() {
let interface = I2CDisplayInterface::new(i2c_bus);
// инициализируем дисплей
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
    .into_buffered_graphics_mode();
display.init().await.unwrap();
}

Стиль текста и позиция

Для вывода текста мы будем использовать моноширинные шрифты. Построитель MonoTextStyleBuilder поможет создать стиль текста, а мы используем шрифт размером 6x10 пикселей. Другие моноширинные шрифты можно найти здесь.

Если у вас многоцветный OLED-дисплей, можно задать разные цвета шрифта. Однако, так как у нас монохромный дисплей, мы используем BinaryColor::On, чтобы задать белый цвет текста. Это просто включает пиксели, нужные для отображения текста.

#![allow(unused)]
fn main() {
let text_style = MonoTextStyleBuilder::new()
    .font(&FONT_6X10)
    .text_color(BinaryColor::On)
    .build();

Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
    .draw(&mut display)
    .unwrap();
}

Базовая линия (baseline) — воображаемая линия, которая определяет выравнивание текста. Мы задаём базовую линию с координатами x = 0 и y = 16. Также указываем, как текст выравнивается в этом пространстве. Перечисление Baseline управляет положением текста относительно базовой линии. Например, Baseline::Top выравнивает верх текста по начальной точке, а Baseline::Bottom — низ текста по начальной точке. Есть и другие варианты, например Middle, Alphabetic.

Рекомендую поменять значения точки и параметр Baseline и посмотреть, как они влияют на результат. Визуальные изменения помогут лучше всё понять.

Затем можно вывести текст на любой объект, который реализует трейт DrawTarget. Буферный режим BufferedGraphicsMode крейта ssd1306 реализует этот трейт, поэтому мы передаём дисплей в функцию draw как изменяемую ссылку.

Flush

Наконец, вызываем функцию flush, которая записывает данные на дисплей. Только после этого обновлённое содержимое появится на OLED-экране.

#![allow(unused)]
fn main() {
display.flush().await.unwrap();
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку hello-oled.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/hello-oled

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};

use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Embedded Graphics
use embedded_graphics::{
    mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
    pixelcolor::BinaryColor,
    prelude::Point,
    prelude::*,
    text::{Baseline, Text},
};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let interface = I2CDisplayInterface::new(i2c_bus);
    // инициализируем дисплей
    let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();
    display.init().await.unwrap();

    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::On)
        .build();

    Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
        .draw(&mut display)
        .unwrap();

    display.flush().await.unwrap();

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Вывод сырого изображения на OLED-дисплей с ESP32

В этом упражнении мы выведем сырое изображение, используя только байтовые массивы. Создадим символ Ом (Ω) в формате 1BPP (1 бит на пиксель).

Изображение 1BPP

Формат 1BPP (1 бит на пиксель) использует один бит для каждого пикселя. Он может представлять только два цвета, обычно чёрный и белый. Если значение бита 0, обычно это полностью чёрный пиксель. Если значение бита 1, обычно это полностью белый пиксель.

Символ Ом мы создадим на сетке 8x5 пикселей в формате 1bpp. Я выделил единицы в байтовом массиве, чтобы показать, как они включают пиксели и формируют символ Ом.

Ширину 8 я выбрал, чтобы пример оставался простым: ширину в 8 пикселей легко представить одним байтом (8 бит). Но если увеличить ширину, она уже не поместится в один байт, и её придётся распределить по нескольким элементам байтового массива. Я объясню это в следующих главах. Пока оставим всё просто.

Символ Ом на OLED-дисплее (128x64)

Покажу, как выглядит символ Ом, помещённый на OLED-дисплей (разрешение 128x64) в позицию нуля (x = 0, y = 0).

Это увеличенная иллюстрация. Когда вы увидите символ на реальном модуле, он будет маленьким.

Ссылки

Код

Надеюсь, теперь вы понимаете, как изображение представлено в байтовом массиве. Перейдём к программированию.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 oled-image

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features»
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

ssd1306 = { version = "0.10.0", features = ["async"] }
embedded-graphics = "0.8.1"

Импорты

Добавьте необходимые импорты:

#![allow(unused)]
fn main() {

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Embedded Graphics
use embedded_graphics::{
    image::{Image, ImageRaw},
    pixelcolor::BinaryColor,
    prelude::Point,
    prelude::*,
};
}

Шаблонный код: инициализация I2C и экземпляра дисплея

Эту часть мы уже разобрали в предыдущей главе.

#![allow(unused)]
fn main() {
let i2c_bus = esp_hal::i2c::master::I2c::new(
    peripherals.I2C0,
    esp_hal::i2c::master::Config::default().with_frequency(Rate::from_khz(400)),
)
.unwrap()
.with_scl(peripherals.GPIO18)
.with_sda(peripherals.GPIO23)
.into_async();

let interface = I2CDisplayInterface::new(i2c_bus);

// инициализируем дисплей
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
    .into_buffered_graphics_mode();
display.init().await.unwrap();
}

Вывод изображения

Мы создали константу с байтовым массивом, которая представляет символ Ом.

#![allow(unused)]
fn main() {
// 8x5 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
    0b00111000,
    0b01000100,
    0b01000100,
    0b00101000,
    0b11101110,
];
}

Создадим сырое изображение с помощью функции ImageRaw::new. Нужно указать ширину изображения (то есть 8) и формат цвета пикселей с помощью синтаксиса turbofish ::<>. Высота изображения будет вычислена автоматически по длине данных и формату. Поскольку наш дисплей двухцветный, мы используем перечисление BinaryColor.

Затем выведем изображение в начальную позицию дисплея — точку ноль (x = 0, y = 0). Наконец, передадим данные на дисплей командой flush.

#![allow(unused)]
fn main() {
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8);

let image = Image::new(&raw_image, Point::zero());

image.draw(&mut display).unwrap();
display.flush().await.unwrap();
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку oled-image.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-image

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Embedded Graphics
use embedded_graphics::{
    image::{Image, ImageRaw},
    pixelcolor::BinaryColor,
    prelude::Point,
    prelude::*,
};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

// 8x5 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
    0b00111000,
    0b01000100,
    0b01000100,
    0b00101000,
    0b11101110,
];

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let interface = I2CDisplayInterface::new(i2c_bus);

    // инициализируем дисплей
    let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();
    display.init().await.unwrap();

    let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8);

    let image = Image::new(&raw_image, Point::zero());

    image.draw(&mut display).unwrap();
    display.flush().await.unwrap();

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Использование нескольких байтов для большей ширины пикселей

В предыдущем примере мы оставались простыми: использовали изображение шириной 8 пикселей, поэтому каждая строка представлялась одним байтом. Однако в реальных задачах может понадобиться больше пикселей. Как их представить? Можно использовать несколько байтов для более широких изображений. Но если так делать, как система поймёт, где столбцы, а где строки?

Именно поэтому в крейте embedded-graphics нужно указывать точную ширину. Указав ширину, система знает, сколько элементов массива использовать для представления строки. По этой ширине (и формату изображения) система затем определяет высоту.

Например, рассмотрим изображение разрешением 31x7 пикселей. Ширина — 31 пиксель, и каждый пиксель представлен 1 битом. Чтобы представить 31 пиксель в байтах, нужно вычислить, сколько байтов потребуется. Поскольку в байте 8 бит, делим общее количество пикселей (31) на 8. Получаем 3 полных байта, которые покрывают 24 пикселя, и ещё один байт для оставшихся 7 пикселей. Итого для 31 пикселя требуется 4 байта.

Крейт embedded-graphics внутри использует такой фрагмент для вычисления высоты. Мы не будем включать этот код в свой проект, но покажу его здесь для справки, чтобы продемонстрировать, как это работает внутри:

#![allow(unused)]
fn main() {
let height = data.len() / bytes_per_row(width, C::Raw::BITS_PER_PIXEL);
//...
//...
const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize {
    (width as usize * bits_per_pixel + 7) / 8
}
}

Здесь длина данных равна 28 (элементам массива), количество бит на пиксель — 1, а ширина изображения — 31. Если применить эту логику, получим bytes_per_row равным 4, а высоту — 7.

Вы можете запустить следующий код здесь же или в Rust Playground, чтобы разобраться в логике:

// 31x7 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
    // 1-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    // 2-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    //3-я строка
    0b00000001,0b10000000,0b00000011,0b00000000,
    //4-я строка
    0b11111111,0b10000000,0b00000011,0b11111110,
    //5-я строка
    0b00000001,0b10000000,0b00000011,0b00000000,
    //6-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    //7-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
];

const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize {
    (width as usize * bits_per_pixel + 7) / 8
}

fn main(){
    const BITS_PER_PIXEL: usize = 1;
    let width = 31;
    let data = IMG_DATA;
    
    println!("Bytes Per Row:{}", bytes_per_row(width,BITS_PER_PIXEL));
    let height = data.len() / bytes_per_row(width, BITS_PER_PIXEL);
    println!("Height: {}", height);
}

Создавать эти байтовые массивы вручную не нужно: можно воспользоваться онлайн-инструментом, например image2bytes, чтобы сгенерировать байтовый массив за вас.

Код

Основные изменения в коде — это данные изображения и его ширина. Так будет выведен символ резистора в стиле IEC-60617.

Основа проекта

Мы скопируем проект с предыдущим изображением и продолжим работу на его основе.

git clone https://github.com/ImplFerris/esp32-projects
cp -r esp32-projects/old-image ~/YOUR_PROJECT_FOLDER/oled-rawimg

Данные изображения

На этот раз мы используем следующие данные, чтобы вывести символ резистора на OLED.

#![allow(unused)]
fn main() {
// 31x7 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
    // 1-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    // 2-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    //3-я строка
    0b00000001,0b10000000,0b00000011,0b00000000,
    //4-я строка
    0b11111111,0b10000000,0b00000011,0b11111110,
    //5-я строка
    0b00000001,0b10000000,0b00000011,0b00000000,
    //6-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    //7-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
];
}

Нужно задать ширину 31. Изображение выведем в точке (x=35, y=35), хотя причин выбрать именно такие координаты особых нет: мне просто хотелось показать что-то, отличное от нулевой точки. Не стесняйтесь экспериментировать с разными значениями точки и изучать другие варианты.

#![allow(unused)]
fn main() {
 let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31);

let image = Image::new(&raw_image, Point::new(35, 35));
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку oled-rawimg.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-rawimg

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Embedded Graphics
use embedded_graphics::{
    image::{Image, ImageRaw},
    pixelcolor::BinaryColor,
    prelude::Point,
    prelude::*,
};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

// 31x7 пикселей
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
    // 1-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    // 2-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    //3-я строка
    0b00000001,0b10000000,0b00000011,0b00000000,
    //4-я строка
    0b11111111,0b10000000,0b00000011,0b11111110,
    //5-я строка
    0b00000001,0b10000000,0b00000011,0b00000000,
    //6-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
    //7-я строка
    0b00000001,0b11111111,0b11111111,0b00000000,
];

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let interface = I2CDisplayInterface::new(i2c_bus);

    // инициализируем дисплей
    let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();
    display.init().await.unwrap();

    let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31);
    let image = Image::new(&raw_image, Point::new(35, 35));

    image.draw(&mut display).unwrap();
    display.flush().await.unwrap();

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Использование файла растрового изображения

Вместо сырых данных изображения можно использовать файлы BMP (.bmp) напрямую, применив крейт tinybmp. tinybmp — это лёгкий парсер BMP, созданный для встраиваемых окружений. Он в первую очередь предназначен для вывода BMP-изображений в DrawTarget embedded_graphics, но его также можно использовать для разбора BMP-файлов в других задачах. Это идеально подходит для наших целей.

Файл BMP

Крейт требует, чтобы изображение было в формате BMP. Если ваше изображение в другом формате, его нужно конвертировать в BMP. Например, в Linux можно использовать такую команду, чтобы превратить PNG в монохромный BMP:

convert ferris.png -monochrome ferris.bmp

Я подготовил файл Ferris в формате BMP, который можно использовать для этого упражнения. Скачать его можно здесь.

файл ferris.bmp

Основа проекта

Мы скопируем проект с предыдущим изображением и продолжим работу на его основе.

git clone https://github.com/ImplFerris/esp32-projects
cp -r esp32-projects/old-image ~/YOUR_PROJECT_FOLDER/oled-bmp

Обновляем Cargo.toml

Нам понадобится ещё один крейт, tinybmp, чтобы загрузить BMP-изображение.

tinybmp = "0.6.0"

Использование BMP-файла

Поместите файл ferris.bmp в папку src. Код довольно простой: загрузите изображение как байты и передайте их в функцию from_slice у Bmp. Затем используйте результат с Image.

#![allow(unused)]
fn main() {
// здесь обычный шаблонный код...

// Подключаем данные BMP-файла.
let bmp_data = include_bytes!("../ferris.bmp");

// Разбираем BMP-файл.
let bmp = Bmp::from_slice(bmp_data).unwrap();

// обычный код:
let image = Image::new(&bmp, Point::new(32, 0));
image.draw(&mut display).unwrap();
display.flush().await.unwrap();
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку oled-bmp.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-bmp

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Embedded Graphics
use embedded_graphics::{image::Image, prelude::Point, prelude::*};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let interface = I2CDisplayInterface::new(i2c_bus);

    // инициализируем дисплей
    let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();
    display.init().await.unwrap();

    // Подключаем данные BMP-файла.
    let bmp_data = include_bytes!("../ferris.bmp");

    // Разбираем BMP-файл.
    let bmp = tinybmp::Bmp::from_slice(bmp_data).unwrap();

    // обычный код:
    let image = Image::new(&bmp, Point::new(32, 0));
    image.draw(&mut display).unwrap();
    display.flush().await.unwrap();

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Термистор

В этом разделе мы будем использовать термистор с ESP32. Термистор — это переменный резистор, который меняет своё сопротивление в зависимости от температуры. Величина изменения сопротивления зависит от его состава. Название происходит от слияния слов «thermal» (тепловой) и «resistor» (резистор).

Термисторы широко применяют для измерения температуры и защиты цепей. С их помощью измеряют температуру масла и охлаждающей жидкости в автомобилях, их используют в медицинском оборудовании. В домашней автоматике термисторы встречаются в умных термостатах, которые управляют отоплением и охлаждением, поддерживая комфортную температуру. Мы будем использовать термистор, чтобы измерять комнатную температуру и выводить результат.

Термисторы делятся на два типа:

  • NTC (Negative Temperature Coefficient, с отрицательным температурным коэффициентом):

    • сопротивление уменьшается при повышении температуры;
    • в основном используются для измерения температуры и ограничения пускового тока;
    • в нашем упражнении мы будем использовать NTC-термистор для измерения температуры. NTC-резистор
  • PTC (Positive Temperature Coefficient, с положительным температурным коэффициентом):

    • сопротивление растёт при повышении температуры;
    • в основном защищают от перегрузки по току и перегрева в роли самовосстанавливающихся предохранителей; их обычно применяют в кондиционерах, медицинских приборах, зарядных устройствах и сварочном оборудовании.

Аппаратные требования

  • Термистор NTC 103: 10 кОм, эпоксидный диск диаметром 5 мм
  • Резистор 10 кОм: используется с термистором для создания делителя напряжения

Как это работает?

Мы создадим делитель напряжения из NTC-термистора и известного резистора (10 кОм). При повышении температуры сопротивление термистора уменьшается. Затем мы вычислим температуру по этому сопротивлению. В следующей главе мы покажем симуляцию термистора в делителе напряжения и напряжение, которое он выдаёт.

Однако, когда термистор подключён к микроконтроллеру, его сопротивление напрямую измерить нельзя. Вместо этого вы получаете выходное напряжение делителя, которое подаётся на вывод АЦП микроконтроллера. Это напряжение считывается как значение АЦП (подробнее см. главу об АЦП). Затем с помощью формул мы вычислим сопротивление по значению АЦП.

Ссылки

NTC и делитель напряжения

Я создал схему на сайте Falstad, и вы можете скачать файл voltage-divider-thermistor.circuitjs.txt, чтобы импортировать его и поэкспериментировать. Эта схема похожа на ту, что мы рассматривали в главе Делитель напряжения. Если вы ещё не проходили этот раздел, настоятельно рекомендую сначала изучить теорию там.

Эта схема включает термистор 10 кОм (R2), сопротивление которого при 25°C равно 10 кОм. Входное напряжение \( V_{in} \) задано равным 3,3 В.

Примечание: когда вы собираете этот делитель напряжения с микроконтроллером, периферия АЦП преобразует Vout в цифровое значение.

Термистор при 25°C

Сопротивление термистора при 25°C составляет 10 кОм, и выходное напряжение (\( V_{out} \)) равно 1,65 В.

термистор при 25°C

Термистор при 38°C

Сопротивление термистора уменьшается из-за его отрицательного температурного коэффициента, что меняет выходное напряжение делителя.

термистор при 38°C

Термистор при 10°C

Сопротивление термистора растёт, что приводит к более высокому выходному напряжению (\( V_{out} \)). термистор при 10°C

Эти изображения приведены только для наглядности: показания вашего термистора могут немного отличаться.

От АЦП к сопротивлению

При подключении термистора к ESP32 мы получаем не сопротивление термистора напрямую, а значение АЦП (см. главу АЦП). Нам нужно вычислить по значению АЦП сопротивление термистора. Оно понадобится для расчёта температуры (об этом поговорим в следующих главах).

Чтобы вычислить сопротивление по показанию АЦП, используем формулу:

\[ R_2 = R_1 \left( \frac{\text{adc_value}}{\text{ADC_MAX} - \text{adc_value}} \right) \]

Где:

  • R2: сопротивление термистора, которое нам нужно вычислить
  • R1: сопротивление резистора, включённого последовательно с термистором (обычно 10 кОм)
  • ADC_MAX: максимальное значение АЦП, 4095 (\( 2^{12} - 1 \)) для 12-битного АЦП
  • adc_value: значение, считанное с АЦП

Функция на Rust


const ADC_MAX: f64 = 4095.0;
const R1_RES: f64 = 10_000.0; 

const fn adc_to_resistance(adc_value: f64) -> f64 {
    let x: f64 = adc_value/(ADC_MAX - adc_value);
    R1_RES * x
}

fn main() {
    let adc_value = 2000; // Наш пример значения АЦП;

    let r2 = adc_to_resistance(adc_value as f64);
    println!("Calculated Resistance (R2): {} Ω", r2);
}

Примечание:

Если вы подключили термистор к питанию, а не к GND, формула будет обратной, так как термистор становится R1.

\[ R_1 = {R_2} \times \left(\frac{\text{ADC_MAX}}{\text{adc_value}} - 1\right) \]

Проблема нелинейности АЦП ESP32

В ранних версиях чипа ESP32, например в том, что установлен на DevKit V1 (скорее всего, он у вас тоже есть), АЦП имеет проблему нелинейности. Когда я использовал сырое значение АЦП для расчёта температуры, результаты постоянно выходили неверными. Это сбивало меня с толку несколько дней, пока я не наткнулся на статью Using a Thermistor with Arduino and Unexpected ESP32 ADC Non-linearity. В ней объяснялась проблема нелинейности АЦП ESP32.

Решение — использовать таблицу поиска (LUT), которая сопоставляет сырое значение АЦП с исправленным. Однако значения таблицы различаются от устройства к устройству, потому что у каждого ESP32 своё внутреннее опорное напряжение, поэтому использовать её как есть может не получиться. В репозитории «esp32-adc-calibrate» есть код для генерации собственной LUT для вашего ESP32.

Я запустил программу калибровки для своего ESP32 и сгенерировал таблицу поиска (LUT) для своего устройства. Её я привожу ниже. Она должна примерно подойти для ESP32 DevKit V1, но помните, что характеристики АЦП ESP32 различаются от устройства к устройству, поэтому LUT не гарантирует точности на вашей плате.

Если хотите сгенерировать LUT специально для своего ESP32, инструкции по калибровке приведены в конце этой главы. Метод калибровки полезен для улучшения показаний АЦП, но это всё равно приближение. По моим тестам, использование LUT даёт заметно лучшие результаты, чем сырое значение АЦП без коррекции, но не делает измерение температуры идеально точным.

Таблица поиска

Мы будем использовать её в коде, чтобы получать исправленное значение.

Пример использования:

#![allow(unused)]
fn main() {
let adc_value = 1900;
let corrected_adc: f64 = ADC_LUT[adc_value as usize]; // it will get you 2038.2
}
#![allow(unused)]
fn main() {
// Таблица поиска (LUT) для исправления нелинейности АЦП
// https://github.com/e-tinkers/esp32-adc-calibrate
// https://www.e-tinkers.com/2019/10/using-a-thermistor-with-arduino-and-unexpected-esp32-adc-non-linearity/
const ADC_LUT: &[f64; 4096] = &[
    0.0, 21.8000, 32.0000, 33.8000, 35.0000, 36.4000, 37.8000, 39.0000, 40.8000, 42.0000, 43.6000,
    44.8000, 46.2000, 47.8000, 49.0000, 50.0000, 51.2000, 52.6000, 53.8000, 54.8000, 56.0000,
    57.0000, 58.4000, 59.8000, 60.8000, 62.0000, 63.0000, 64.2000, 65.4000, 66.6000, 67.8000,
    68.8000, 69.8000, 70.8000, 72.0000, 73.0000, 74.0000, 75.2000, 76.4000, 77.6000, 78.8000,
    79.8000, 80.8000, 81.8000, 82.8000, 84.0000, 85.0000, 86.0000, 87.0000, 88.0000, 89.0000,
    90.0000, 91.0000, 92.2000, 93.2000, 94.4000, 95.4000, 96.6000, 97.8000, 98.8000, 99.8000,
    101.0000, 102.0000, 103.2000, 104.4000, 105.6000, 106.8000, 107.8000, 109.0000, 110.0000,
    111.0000, 112.4000, 113.8000, 115.4000, 117.0000, 118.6000, 120.0000, 121.6000, 123.0000,
    124.8000, 126.0000, 127.8000, 128.8000, 129.8000, 130.8000, 131.8000, 132.6000, 133.6000,
    134.4000, 135.2000, 136.2000, 137.0000, 138.0000, 139.0000, 140.0000, 141.0000, 141.8000,
    142.8000, 143.8000, 144.8000, 146.0000, 147.2000, 148.8000, 149.8000, 151.0000, 152.2000,
    153.6000, 154.8000, 156.0000, 157.2000, 158.6000, 159.8000, 160.8000, 161.8000, 162.8000,
    163.8000, 164.8000, 165.8000, 166.8000, 167.8000, 168.8000, 169.8000, 170.8000, 171.8000,
    172.8000, 173.8000, 174.8000, 175.8000, 176.8000, 177.8000, 179.0000, 180.0000, 181.4000,
    182.6000, 183.8000, 184.8000, 186.0000, 187.0000, 188.2000, 189.6000, 190.8000, 191.8000,
    193.0000, 194.0000, 195.4000, 196.8000, 197.8000, 199.0000, 200.0000, 201.2000, 202.6000,
    203.8000, 205.0000, 206.0000, 207.2000, 208.4000, 209.6000, 210.6000, 211.8000, 212.8000,
    213.8000, 214.8000, 215.8000, 216.8000, 217.8000, 218.8000, 220.0000, 221.0000, 222.0000,
    223.0000, 224.0000, 225.0000, 226.0000, 227.0000, 228.0000, 229.0000, 230.2000, 231.2000,
    232.2000, 233.4000, 234.4000, 235.4000, 236.4000, 237.6000, 238.6000, 239.6000, 240.8000,
    242.0000, 243.4000, 244.8000, 246.0000, 247.4000, 248.8000, 250.0000, 251.6000, 252.8000,
    254.0000, 255.6000, 256.8000, 257.8000, 258.8000, 259.6000, 260.6000, 261.4000, 262.4000,
    263.4000, 264.2000, 265.2000, 266.0000, 267.0000, 268.0000, 269.0000, 270.0000, 271.0000,
    272.0000, 273.0000, 274.6000, 275.8000, 277.0000, 278.4000, 279.8000, 281.0000, 282.2000,
    283.8000, 284.8000, 286.0000, 287.6000, 288.8000, 289.8000, 290.8000, 291.8000, 292.8000,
    293.8000, 294.8000, 295.8000, 296.8000, 297.8000, 298.8000, 299.8000, 300.8000, 301.8000,
    302.8000, 303.8000, 304.8000, 305.8000, 307.0000, 308.0000, 309.0000, 310.0000, 311.0000,
    312.2000, 313.4000, 314.6000, 315.8000, 316.8000, 317.8000, 318.8000, 320.0000, 321.0000,
    322.4000, 323.8000, 325.0000, 326.2000, 327.6000, 328.8000, 330.0000, 331.4000, 332.8000,
    334.0000, 335.0000, 336.4000, 337.6000, 338.8000, 339.8000, 340.8000, 342.0000, 343.0000,
    344.0000, 345.2000, 346.4000, 347.6000, 348.8000, 349.8000, 350.8000, 352.0000, 353.0000,
    354.0000, 355.0000, 356.0000, 357.0000, 358.2000, 359.4000, 360.4000, 361.6000, 362.6000,
    363.8000, 364.8000, 365.8000, 366.8000, 367.8000, 368.8000, 370.0000, 371.0000, 372.0000,
    373.0000, 374.0000, 375.0000, 376.2000, 377.4000, 378.4000, 379.6000, 380.6000, 381.8000,
    382.8000, 383.8000, 384.8000, 386.0000, 387.2000, 388.6000, 389.8000, 390.8000, 392.0000,
    393.2000, 394.6000, 395.8000, 396.8000, 398.0000, 399.2000, 400.6000, 401.8000, 403.0000,
    404.8000, 406.0000, 407.2000, 408.8000, 410.0000, 411.2000, 412.8000, 414.0000, 415.4000,
    416.8000, 417.8000, 418.6000, 419.6000, 420.6000, 421.4000, 422.4000, 423.4000, 424.2000,
    425.2000, 426.2000, 427.0000, 428.0000, 429.0000, 430.0000, 431.0000, 432.0000, 433.6000,
    435.0000, 436.6000, 438.0000, 439.4000, 441.0000, 442.4000, 443.8000, 445.4000, 446.8000,
    448.2000, 449.0000, 449.8000, 450.8000, 451.4000, 452.0000, 453.0000, 453.8000, 454.8000,
    455.2000, 456.0000, 457.0000, 457.8000, 458.6000, 459.2000, 460.0000, 460.8000, 461.8000,
    462.4000, 463.0000, 464.0000, 465.0000, 466.0000, 467.2000, 468.6000, 469.8000, 470.8000,
    471.8000, 473.0000, 474.0000, 475.2000, 476.4000, 477.8000, 478.8000, 479.8000, 481.0000,
    482.8000, 484.0000, 485.4000, 486.8000, 488.0000, 489.8000, 491.0000, 492.4000, 493.8000,
    495.2000, 496.6000, 497.4000, 498.2000, 499.0000, 500.0000, 501.0000, 502.0000, 502.8000,
    503.8000, 504.8000, 505.8000, 506.6000, 507.4000, 508.2000, 509.0000, 510.0000, 511.0000,
    512.0000, 513.0000, 514.2000, 515.6000, 516.8000, 518.0000, 519.0000, 520.4000, 521.8000,
    522.8000, 524.0000, 525.2000, 526.6000, 527.8000, 529.0000, 530.0000, 531.2000, 532.6000,
    533.8000, 535.0000, 536.0000, 537.2000, 538.6000, 539.8000, 541.0000, 542.0000, 543.2000,
    544.6000, 545.6000, 546.8000, 547.8000, 548.8000, 549.8000, 550.8000, 551.8000, 552.8000,
    553.8000, 555.0000, 556.0000, 557.0000, 558.0000, 559.0000, 560.0000, 561.4000, 562.8000,
    563.8000, 565.0000, 566.4000, 567.8000, 568.8000, 570.0000, 571.4000, 572.8000, 573.8000,
    575.0000, 576.2000, 577.2000, 578.4000, 579.4000, 580.4000, 581.4000, 582.4000, 583.4000,
    584.6000, 585.6000, 586.6000, 587.6000, 588.6000, 589.6000, 590.6000, 591.8000, 592.8000,
    593.8000, 595.0000, 596.0000, 597.0000, 598.2000, 599.6000, 600.8000, 601.8000, 603.0000,
    604.0000, 605.0000, 606.2000, 607.6000, 608.6000, 609.0000, 610.0000, 610.8000, 611.6000,
    612.2000, 613.0000, 613.8000, 614.8000, 615.2000, 616.0000, 616.8000, 617.8000, 618.4000,
    619.0000, 620.0000, 620.8000, 621.4000, 622.0000, 623.0000, 623.8000, 624.8000, 625.8000,
    626.8000, 627.8000, 629.0000, 630.0000, 631.0000, 632.0000, 633.0000, 634.2000, 635.4000,
    636.6000, 637.6000, 638.8000, 639.8000, 641.0000, 642.0000, 643.6000, 644.8000, 646.0000,
    647.4000, 648.8000, 650.0000, 651.4000, 652.8000, 654.0000, 655.2000, 656.6000, 657.8000,
    658.8000, 659.8000, 660.8000, 661.8000, 663.0000, 664.0000, 665.0000, 666.0000, 667.2000,
    668.2000, 669.4000, 670.6000, 671.6000, 672.8000, 674.0000, 675.6000, 676.8000, 678.0000,
    679.8000, 681.0000, 682.2000, 683.8000, 685.0000, 686.4000, 687.8000, 689.0000, 690.0000,
    691.0000, 692.0000, 693.0000, 694.0000, 695.0000, 696.2000, 697.2000, 698.2000, 699.4000,
    700.4000, 701.6000, 702.6000, 703.8000, 704.6000, 705.4000, 706.2000, 707.0000, 708.0000,
    708.8000, 709.8000, 710.8000, 711.6000, 712.4000, 713.0000, 714.0000, 715.0000, 715.8000,
    716.8000, 717.8000, 718.6000, 719.2000, 720.0000, 721.8000, 723.0000, 724.4000, 725.8000,
    727.0000, 728.8000, 730.0000, 731.4000, 732.8000, 734.0000, 735.6000, 736.8000, 737.8000,
    738.8000, 739.8000, 740.8000, 742.0000, 743.0000, 744.0000, 745.0000, 746.0000, 747.0000,
    748.0000, 749.0000, 750.0000, 751.0000, 752.2000, 753.6000, 754.8000, 756.0000, 757.4000,
    758.8000, 760.0000, 761.2000, 762.6000, 763.8000, 765.0000, 766.4000, 767.8000, 768.8000,
    770.0000, 771.0000, 772.0000, 773.0000, 774.2000, 775.4000, 776.6000, 777.8000, 778.8000,
    779.8000, 780.8000, 782.0000, 783.0000, 784.0000, 785.2000, 786.6000, 787.8000, 789.0000,
    790.0000, 791.4000, 792.8000, 793.8000, 795.0000, 796.0000, 797.4000, 798.8000, 799.8000,
    801.0000, 802.0000, 803.0000, 804.4000, 805.6000, 806.8000, 807.8000, 808.8000, 810.0000,
    811.0000, 812.0000, 813.2000, 814.4000, 815.8000, 816.8000, 817.8000, 818.8000, 819.8000,
    820.8000, 821.8000, 822.8000, 823.8000, 824.8000, 825.8000, 826.8000, 827.8000, 828.8000,
    829.8000, 830.8000, 831.8000, 833.0000, 834.0000, 835.0000, 836.2000, 837.4000, 838.6000,
    839.8000, 840.8000, 841.8000, 843.0000, 844.0000, 845.0000, 846.2000, 847.4000, 848.8000,
    849.8000, 851.0000, 852.8000, 853.8000, 855.0000, 856.8000, 857.8000, 859.0000, 860.8000,
    861.8000, 863.0000, 864.6000, 865.4000, 866.4000, 867.2000, 868.2000, 869.0000, 870.0000,
    871.0000, 872.0000, 873.0000, 874.0000, 875.0000, 876.0000, 876.8000, 877.8000, 878.8000,
    879.8000, 880.8000, 881.8000, 883.0000, 884.0000, 885.0000, 886.0000, 887.0000, 888.2000,
    889.2000, 890.4000, 891.6000, 892.6000, 893.8000, 894.8000, 895.8000, 897.0000, 898.2000,
    899.8000, 901.0000, 902.0000, 903.6000, 904.8000, 906.0000, 907.6000, 908.8000, 910.0000,
    911.4000, 912.8000, 913.4000, 914.2000, 915.0000, 916.0000, 916.8000, 917.8000, 918.8000,
    919.6000, 920.2000, 921.0000, 922.0000, 923.0000, 923.8000, 924.8000, 925.6000, 926.4000,
    927.2000, 928.0000, 929.4000, 930.8000, 932.0000, 933.2000, 934.8000, 935.8000, 937.0000,
    938.4000, 939.8000, 941.0000, 942.2000, 943.8000, 944.8000, 946.0000, 947.4000, 948.8000,
    950.0000, 951.0000, 952.6000, 953.8000, 955.0000, 956.2000, 957.8000, 958.8000, 960.0000,
    961.0000, 962.2000, 963.4000, 964.6000, 965.8000, 966.8000, 967.8000, 969.0000, 970.0000,
    971.0000, 972.0000, 973.2000, 974.4000, 975.6000, 976.6000, 977.4000, 978.2000, 979.2000,
    980.0000, 981.0000, 982.0000, 983.0000, 983.8000, 984.8000, 985.8000, 986.8000, 987.6000,
    988.4000, 989.2000, 990.0000, 991.0000, 992.0000, 993.0000, 994.0000, 995.4000, 996.6000,
    997.8000, 998.8000, 1000.0000, 1001.0000, 1002.0000, 1003.2000, 1004.4000, 1005.8000,
    1006.8000, 1007.8000, 1008.8000, 1009.0000, 1009.8000, 1010.2000, 1010.8000, 1011.4000,
    1012.0000, 1012.6000, 1013.0000, 1013.8000, 1014.0000, 1014.8000, 1015.4000, 1016.0000,
    1016.6000, 1017.0000, 1017.8000, 1018.0000, 1018.8000, 1019.2000, 1019.8000, 1020.6000,
    1021.0000, 1021.8000, 1022.0000, 1022.8000, 1023.2000, 1023.8000, 1024.8000, 1026.0000,
    1027.6000, 1028.8000, 1030.2000, 1031.8000, 1033.0000, 1034.4000, 1035.8000, 1037.0000,
    1038.8000, 1040.0000, 1041.0000, 1042.0000, 1043.0000, 1044.0000, 1045.0000, 1046.0000,
    1047.0000, 1048.0000, 1049.0000, 1050.0000, 1051.0000, 1052.0000, 1053.0000, 1054.0000,
    1055.0000, 1056.0000, 1057.0000, 1058.2000, 1059.6000, 1060.8000, 1061.8000, 1063.0000,
    1064.0000, 1065.2000, 1066.6000, 1067.8000, 1068.8000, 1070.0000, 1071.0000, 1072.2000,
    1073.0000, 1074.0000, 1075.0000, 1076.0000, 1077.0000, 1078.0000, 1078.8000, 1079.8000,
    1080.8000, 1081.8000, 1082.8000, 1083.8000, 1084.6000, 1085.6000, 1086.4000, 1087.2000,
    1088.2000, 1089.6000, 1090.8000, 1092.0000, 1093.0000, 1094.4000, 1095.8000, 1096.8000,
    1098.0000, 1099.2000, 1100.8000, 1101.8000, 1103.0000, 1104.0000, 1105.2000, 1106.4000,
    1107.6000, 1108.8000, 1109.8000, 1110.8000, 1112.0000, 1113.0000, 1114.0000, 1115.2000,
    1116.4000, 1117.6000, 1118.8000, 1119.8000, 1120.8000, 1121.8000, 1123.0000, 1124.0000,
    1125.0000, 1126.0000, 1127.0000, 1128.2000, 1129.4000, 1130.6000, 1131.6000, 1132.8000,
    1133.8000, 1134.8000, 1135.8000, 1136.8000, 1137.8000, 1138.8000, 1139.6000, 1140.4000,
    1141.2000, 1142.0000, 1143.0000, 1144.0000, 1144.8000, 1145.8000, 1146.8000, 1147.6000,
    1148.4000, 1149.2000, 1150.0000, 1151.0000, 1152.0000, 1153.0000, 1154.0000, 1155.0000,
    1156.0000, 1157.2000, 1158.4000, 1159.6000, 1160.8000, 1161.8000, 1162.8000, 1163.8000,
    1164.8000, 1166.0000, 1167.0000, 1168.0000, 1169.6000, 1170.8000, 1172.0000, 1173.8000,
    1175.0000, 1176.2000, 1177.8000, 1179.0000, 1180.4000, 1181.8000, 1183.0000, 1184.4000,
    1185.0000, 1186.0000, 1186.8000, 1187.6000, 1188.2000, 1189.0000, 1189.8000, 1190.8000,
    1191.6000, 1192.0000, 1193.0000, 1193.8000, 1194.8000, 1195.4000, 1196.0000, 1197.0000,
    1197.8000, 1198.6000, 1199.2000, 1200.0000, 1201.2000, 1202.6000, 1203.8000, 1205.0000,
    1206.2000, 1207.6000, 1208.8000, 1210.0000, 1211.0000, 1212.6000, 1213.8000, 1215.0000,
    1216.0000, 1217.2000, 1218.2000, 1219.4000, 1220.6000, 1221.6000, 1222.8000, 1223.8000,
    1224.8000, 1225.8000, 1226.8000, 1227.8000, 1229.0000, 1230.0000, 1231.0000, 1232.0000,
    1233.0000, 1234.2000, 1235.4000, 1236.6000, 1237.8000, 1238.8000, 1239.8000, 1241.0000,
    1242.0000, 1243.0000, 1244.0000, 1245.2000, 1246.4000, 1247.6000, 1248.8000, 1249.8000,
    1250.6000, 1251.6000, 1252.6000, 1253.6000, 1254.4000, 1255.4000, 1256.4000, 1257.4000,
    1258.2000, 1259.2000, 1260.2000, 1261.2000, 1262.0000, 1263.0000, 1264.0000, 1265.6000,
    1266.8000, 1268.0000, 1269.4000, 1270.8000, 1272.0000, 1273.4000, 1274.8000, 1276.0000,
    1277.2000, 1278.8000, 1280.0000, 1281.0000, 1282.0000, 1283.0000, 1284.0000, 1285.0000,
    1285.8000, 1286.8000, 1287.8000, 1288.8000, 1289.8000, 1290.8000, 1291.8000, 1292.8000,
    1293.8000, 1294.8000, 1295.8000, 1296.8000, 1298.0000, 1299.2000, 1300.6000, 1301.8000,
    1302.8000, 1304.0000, 1305.2000, 1306.4000, 1307.8000, 1308.8000, 1310.0000, 1311.0000,
    1312.4000, 1313.6000, 1314.8000, 1315.8000, 1316.8000, 1317.8000, 1319.0000, 1320.0000,
    1321.0000, 1322.0000, 1323.2000, 1324.2000, 1325.4000, 1326.6000, 1327.8000, 1328.8000,
    1329.8000, 1330.8000, 1332.0000, 1333.0000, 1334.0000, 1335.0000, 1336.2000, 1337.4000,
    1338.6000, 1339.6000, 1340.8000, 1341.8000, 1342.8000, 1343.8000, 1345.0000, 1346.2000,
    1347.6000, 1348.8000, 1349.8000, 1351.0000, 1352.0000, 1353.4000, 1354.8000, 1355.8000,
    1357.0000, 1358.0000, 1359.4000, 1360.8000, 1361.8000, 1362.8000, 1363.8000, 1364.8000,
    1365.8000, 1366.8000, 1367.8000, 1368.8000, 1369.8000, 1370.8000, 1371.8000, 1372.8000,
    1373.8000, 1374.8000, 1375.8000, 1376.8000, 1377.8000, 1379.0000, 1380.2000, 1381.6000,
    1382.8000, 1383.8000, 1385.0000, 1386.2000, 1387.6000, 1388.8000, 1389.8000, 1391.0000,
    1392.0000, 1393.2000, 1394.4000, 1395.6000, 1396.6000, 1397.8000, 1398.8000, 1399.8000,
    1400.8000, 1401.8000, 1403.0000, 1404.0000, 1405.0000, 1406.0000, 1407.0000, 1408.2000,
    1409.2000, 1410.4000, 1411.4000, 1412.6000, 1413.6000, 1414.8000, 1415.8000, 1416.8000,
    1417.8000, 1418.8000, 1419.8000, 1420.8000, 1422.0000, 1423.0000, 1424.0000, 1425.0000,
    1426.0000, 1427.0000, 1428.0000, 1428.8000, 1429.8000, 1430.8000, 1431.8000, 1432.8000,
    1433.8000, 1434.8000, 1435.8000, 1436.8000, 1437.8000, 1438.8000, 1439.8000, 1440.8000,
    1441.8000, 1443.0000, 1444.2000, 1445.6000, 1446.8000, 1448.0000, 1449.0000, 1450.6000,
    1451.8000, 1453.0000, 1454.0000, 1455.4000, 1456.8000, 1457.8000, 1459.0000, 1460.0000,
    1461.4000, 1462.6000, 1463.8000, 1465.0000, 1466.0000, 1467.2000, 1468.6000, 1469.8000,
    1470.8000, 1472.0000, 1473.0000, 1474.0000, 1475.2000, 1476.4000, 1477.6000, 1478.8000,
    1479.8000, 1480.8000, 1481.8000, 1483.0000, 1484.0000, 1485.0000, 1486.2000, 1487.4000,
    1488.6000, 1489.6000, 1490.8000, 1491.8000, 1492.8000, 1493.8000, 1494.8000, 1496.0000,
    1497.0000, 1498.0000, 1499.0000, 1500.0000, 1501.2000, 1502.4000, 1503.6000, 1504.6000,
    1505.8000, 1506.8000, 1508.0000, 1509.0000, 1510.0000, 1511.2000, 1512.4000, 1513.8000,
    1514.8000, 1515.8000, 1517.0000, 1518.0000, 1519.0000, 1520.2000, 1521.0000, 1522.0000,
    1523.0000, 1523.8000, 1524.8000, 1525.6000, 1526.4000, 1527.2000, 1528.0000, 1529.0000,
    1529.8000, 1530.8000, 1531.8000, 1532.6000, 1533.4000, 1534.2000, 1535.0000, 1536.0000,
    1537.0000, 1538.2000, 1539.4000, 1540.8000, 1541.8000, 1543.0000, 1544.0000, 1545.2000,
    1546.6000, 1547.8000, 1548.8000, 1550.0000, 1551.0000, 1552.2000, 1553.6000, 1554.8000,
    1556.0000, 1557.0000, 1558.4000, 1559.8000, 1560.8000, 1562.0000, 1563.0000, 1564.4000,
    1565.8000, 1566.8000, 1568.0000, 1569.0000, 1570.0000, 1571.0000, 1571.8000, 1572.8000,
    1573.8000, 1574.8000, 1575.8000, 1576.8000, 1577.8000, 1578.8000, 1579.6000, 1580.6000,
    1581.6000, 1582.4000, 1583.4000, 1584.4000, 1585.8000, 1587.0000, 1588.8000, 1590.0000,
    1591.2000, 1592.8000, 1594.0000, 1595.6000, 1597.0000, 1598.2000, 1599.8000, 1600.8000,
    1601.8000, 1602.6000, 1603.2000, 1604.0000, 1605.0000, 1605.8000, 1606.8000, 1607.8000,
    1608.6000, 1609.2000, 1610.0000, 1611.0000, 1612.0000, 1612.8000, 1613.8000, 1614.6000,
    1615.2000, 1616.0000, 1617.4000, 1618.6000, 1619.8000, 1620.8000, 1622.0000, 1623.0000,
    1624.4000, 1625.8000, 1626.8000, 1627.8000, 1629.0000, 1630.0000, 1631.4000, 1632.6000,
    1633.8000, 1634.8000, 1635.8000, 1637.0000, 1638.0000, 1639.0000, 1640.4000, 1641.6000,
    1642.8000, 1643.8000, 1644.8000, 1646.0000, 1647.0000, 1648.0000, 1649.2000, 1650.2000,
    1651.4000, 1652.6000, 1653.8000, 1654.8000, 1655.8000, 1656.8000, 1658.0000, 1659.0000,
    1660.0000, 1661.0000, 1662.0000, 1663.2000, 1664.6000, 1665.8000, 1667.0000, 1668.2000,
    1669.8000, 1671.0000, 1672.0000, 1673.6000, 1674.8000, 1676.0000, 1677.4000, 1678.8000,
    1680.0000, 1681.0000, 1682.0000, 1682.8000, 1683.8000, 1684.8000, 1685.8000, 1686.8000,
    1687.8000, 1688.8000, 1689.6000, 1690.6000, 1691.4000, 1692.4000, 1693.2000, 1694.0000,
    1695.0000, 1696.0000, 1697.8000, 1699.2000, 1700.8000, 1702.6000, 1704.0000, 1705.8000,
    1707.2000, 1708.8000, 1710.6000, 1712.0000, 1712.8000, 1713.6000, 1714.0000, 1715.0000,
    1715.8000, 1716.2000, 1717.0000, 1717.8000, 1718.4000, 1719.0000, 1719.8000, 1720.6000,
    1721.0000, 1722.0000, 1722.8000, 1723.2000, 1724.0000, 1724.8000, 1725.4000, 1726.0000,
    1726.8000, 1727.6000, 1728.2000, 1729.2000, 1730.2000, 1731.2000, 1732.2000, 1733.2000,
    1734.2000, 1735.2000, 1736.2000, 1737.4000, 1738.4000, 1739.4000, 1740.4000, 1741.4000,
    1742.4000, 1743.4000, 1744.4000, 1745.4000, 1746.4000, 1747.4000, 1748.4000, 1749.4000,
    1750.4000, 1751.4000, 1752.4000, 1753.4000, 1754.4000, 1755.4000, 1756.4000, 1757.4000,
    1758.4000, 1759.4000, 1760.6000, 1761.8000, 1763.0000, 1764.0000, 1765.4000, 1766.8000,
    1767.8000, 1769.0000, 1770.4000, 1771.8000, 1772.8000, 1774.0000, 1775.2000, 1776.6000,
    1777.8000, 1778.8000, 1780.0000, 1781.0000, 1782.0000, 1783.2000, 1784.4000, 1785.8000,
    1786.8000, 1787.8000, 1789.0000, 1790.0000, 1791.0000, 1792.2000, 1793.6000, 1794.8000,
    1795.8000, 1797.0000, 1798.0000, 1799.2000, 1800.4000, 1801.8000, 1802.8000, 1803.8000,
    1805.0000, 1806.0000, 1807.2000, 1808.6000, 1809.8000, 1810.8000, 1811.8000, 1812.8000,
    1813.8000, 1815.0000, 1816.0000, 1817.0000, 1818.0000, 1819.0000, 1820.2000, 1821.2000,
    1822.4000, 1823.6000, 1824.8000, 1825.8000, 1826.8000, 1828.0000, 1829.0000, 1830.4000,
    1831.8000, 1832.8000, 1833.8000, 1835.0000, 1836.0000, 1837.4000, 1838.6000, 1839.8000,
    1840.8000, 1842.0000, 1843.2000, 1844.6000, 1845.8000, 1847.0000, 1848.0000, 1849.4000,
    1850.8000, 1851.8000, 1853.0000, 1854.0000, 1855.4000, 1856.6000, 1857.6000, 1858.6000,
    1859.6000, 1860.4000, 1861.4000, 1862.4000, 1863.4000, 1864.2000, 1865.2000, 1866.2000,
    1867.2000, 1868.0000, 1869.0000, 1870.0000, 1871.0000, 1872.0000, 1873.2000, 1874.6000,
    1875.8000, 1876.8000, 1878.0000, 1879.0000, 1880.2000, 1881.6000, 1882.8000, 1883.8000,
    1885.0000, 1886.0000, 1887.2000, 1888.6000, 1889.6000, 1890.6000, 1891.8000, 1892.8000,
    1893.8000, 1894.8000, 1895.8000, 1896.8000, 1897.8000, 1898.8000, 1900.0000, 1901.0000,
    1902.0000, 1903.0000, 1904.0000, 1905.0000, 1906.0000, 1907.0000, 1908.0000, 1909.0000,
    1910.0000, 1911.2000, 1912.2000, 1913.2000, 1914.2000, 1915.4000, 1916.4000, 1917.4000,
    1918.4000, 1919.6000, 1920.8000, 1921.8000, 1923.0000, 1924.0000, 1925.6000, 1926.8000,
    1928.0000, 1929.0000, 1930.4000, 1931.8000, 1932.8000, 1934.0000, 1935.2000, 1936.4000,
    1937.6000, 1938.6000, 1939.6000, 1940.6000, 1941.6000, 1942.8000, 1943.8000, 1944.8000,
    1945.8000, 1946.8000, 1947.8000, 1948.8000, 1949.8000, 1950.8000, 1951.8000, 1952.8000,
    1953.8000, 1955.0000, 1956.0000, 1957.0000, 1958.0000, 1959.2000, 1960.4000, 1961.4000,
    1962.6000, 1963.8000, 1964.8000, 1965.8000, 1966.8000, 1968.0000, 1969.0000, 1970.0000,
    1971.0000, 1972.0000, 1973.0000, 1974.2000, 1975.2000, 1976.4000, 1977.4000, 1978.6000,
    1979.8000, 1980.8000, 1981.8000, 1982.8000, 1983.8000, 1985.0000, 1986.2000, 1987.8000,
    1989.0000, 1990.0000, 1991.8000, 1992.8000, 1994.0000, 1995.6000, 1996.8000, 1998.0000,
    1999.4000, 2000.8000, 2001.8000, 2002.8000, 2004.0000, 2005.0000, 2006.0000, 2007.2000,
    2008.4000, 2009.6000, 2010.8000, 2011.8000, 2012.8000, 2013.8000, 2015.0000, 2016.0000,
    2017.0000, 2018.0000, 2019.2000, 2020.2000, 2021.4000, 2022.4000, 2023.6000, 2024.8000,
    2025.8000, 2026.8000, 2027.8000, 2028.8000, 2029.8000, 2031.0000, 2032.0000, 2032.8000,
    2033.0000, 2033.6000, 2034.0000, 2034.6000, 2035.0000, 2035.4000, 2036.0000, 2036.4000,
    2036.8000, 2037.2000, 2037.8000, 2038.2000, 2038.8000, 2039.2000, 2039.8000, 2040.0000,
    2040.8000, 2041.0000, 2041.8000, 2042.0000, 2042.8000, 2043.0000, 2043.6000, 2044.0000,
    2044.6000, 2045.0000, 2045.6000, 2046.0000, 2046.4000, 2046.8000, 2047.4000, 2047.8000,
    2048.6001, 2049.8000, 2050.8000, 2052.0000, 2053.2000, 2054.6001, 2055.8000, 2056.8000,
    2058.0000, 2059.0000, 2060.3999, 2061.8000, 2062.8000, 2064.0000, 2065.0000, 2066.0000,
    2066.8000, 2067.8000, 2068.8000, 2069.8000, 2070.8000, 2071.8000, 2072.8000, 2073.8000,
    2074.8000, 2075.6001, 2076.6001, 2077.6001, 2078.3999, 2079.3999, 2080.2000, 2081.2000,
    2082.2000, 2083.0000, 2084.0000, 2085.0000, 2086.0000, 2087.0000, 2088.0000, 2089.0000,
    2090.0000, 2091.0000, 2092.0000, 2092.8000, 2093.8000, 2094.8000, 2095.8000, 2097.0000,
    2098.0000, 2099.6001, 2100.8000, 2102.0000, 2103.3999, 2104.8000, 2106.0000, 2107.3999,
    2108.8000, 2110.0000, 2111.2000, 2112.8000, 2113.8000, 2115.0000, 2116.6001, 2117.8000,
    2119.0000, 2120.2000, 2121.8000, 2123.0000, 2124.0000, 2125.6001, 2126.8000, 2128.0000,
    2129.0000, 2130.0000, 2130.8000, 2131.8000, 2132.8000, 2133.8000, 2134.8000, 2135.6001,
    2136.3999, 2137.2000, 2138.0000, 2139.0000, 2140.0000, 2141.0000, 2142.0000, 2142.8000,
    2143.8000, 2144.8000, 2146.0000, 2147.8000, 2149.0000, 2150.0000, 2151.8000, 2153.0000,
    2154.2000, 2155.8000, 2157.0000, 2158.2000, 2159.8000, 2160.8000, 2161.8000, 2163.0000,
    2164.0000, 2165.0000, 2166.0000, 2167.0000, 2168.0000, 2169.2000, 2170.2000, 2171.3999,
    2172.6001, 2173.6001, 2174.8000, 2175.8000, 2176.8000, 2177.8000, 2178.8000, 2180.0000,
    2181.0000, 2182.0000, 2183.0000, 2184.0000, 2185.2000, 2186.3999, 2187.3999, 2188.6001,
    2189.8000, 2190.8000, 2191.8000, 2193.0000, 2194.0000, 2195.6001, 2196.8000, 2198.0000,
    2199.3999, 2200.8000, 2202.0000, 2203.2000, 2204.8000, 2205.8000, 2207.0000, 2208.3999,
    2209.6001, 2210.8000, 2211.8000, 2212.8000, 2213.8000, 2215.0000, 2216.0000, 2217.0000,
    2218.2000, 2219.3999, 2220.6001, 2221.8000, 2222.8000, 2223.8000, 2224.8000, 2225.6001,
    2226.3999, 2227.0000, 2228.0000, 2228.8000, 2229.8000, 2230.6001, 2231.3999, 2232.0000,
    2233.0000, 2233.8000, 2234.8000, 2235.6001, 2236.2000, 2237.0000, 2238.0000, 2238.8000,
    2239.8000, 2240.8000, 2241.8000, 2242.8000, 2243.8000, 2244.8000, 2245.8000, 2246.8000,
    2247.8000, 2248.8000, 2249.8000, 2250.8000, 2251.8000, 2252.8000, 2253.8000, 2254.8000,
    2255.8000, 2256.8000, 2257.8000, 2258.8000, 2260.0000, 2261.0000, 2262.2000, 2263.3999,
    2264.8000, 2265.8000, 2266.8000, 2268.0000, 2269.0000, 2270.2000, 2271.3999, 2272.6001,
    2273.6001, 2274.3999, 2275.3999, 2276.2000, 2277.2000, 2278.0000, 2279.0000, 2280.0000,
    2281.0000, 2282.0000, 2283.0000, 2284.0000, 2285.0000, 2286.0000, 2286.8000, 2287.8000,
    2289.0000, 2290.0000, 2291.0000, 2292.2000, 2293.6001, 2294.8000, 2295.8000, 2296.8000,
    2298.0000, 2299.0000, 2300.2000, 2301.6001, 2302.8000, 2303.8000, 2304.8000, 2306.0000,
    2307.0000, 2308.2000, 2309.6001, 2310.8000, 2311.8000, 2312.8000, 2314.0000, 2315.0000,
    2316.2000, 2317.6001, 2318.8000, 2319.8000, 2321.0000, 2322.0000, 2323.3999, 2324.8000,
    2325.8000, 2327.0000, 2328.2000, 2329.6001, 2330.8000, 2332.0000, 2333.0000, 2334.3999,
    2335.8000, 2336.8000, 2338.0000, 2339.0000, 2340.2000, 2341.6001, 2342.8000, 2343.8000,
    2345.0000, 2346.0000, 2347.0000, 2348.3999, 2349.6001, 2350.8000, 2351.8000, 2353.0000,
    2354.0000, 2355.0000, 2356.2000, 2357.3999, 2358.6001, 2359.8000, 2360.8000, 2361.8000,
    2363.0000, 2364.0000, 2365.0000, 2366.2000, 2367.3999, 2368.3999, 2369.3999, 2370.2000,
    2371.0000, 2372.0000, 2373.0000, 2374.0000, 2375.0000, 2375.8000, 2376.8000, 2377.8000,
    2378.8000, 2379.8000, 2380.6001, 2381.3999, 2382.3999, 2383.2000, 2384.2000, 2385.8000,
    2387.0000, 2388.2000, 2389.8000, 2391.0000, 2392.2000, 2393.8000, 2395.0000, 2396.2000,
    2397.8000, 2399.0000, 2400.2000, 2401.2000, 2402.2000, 2403.2000, 2404.2000, 2405.2000,
    2406.2000, 2407.2000, 2408.2000, 2409.2000, 2410.2000, 2411.2000, 2412.2000, 2413.2000,
    2414.2000, 2415.2000, 2416.2000, 2417.3999, 2418.8000, 2419.8000, 2420.8000, 2422.0000,
    2423.0000, 2424.2000, 2425.6001, 2426.8000, 2427.8000, 2429.0000, 2430.0000, 2431.2000,
    2432.3999, 2433.8000, 2435.0000, 2436.2000, 2437.8000, 2438.8000, 2440.0000, 2441.3999,
    2442.8000, 2444.0000, 2445.2000, 2446.8000, 2447.8000, 2449.0000, 2450.0000, 2451.0000,
    2452.0000, 2453.0000, 2454.2000, 2455.3999, 2456.3999, 2457.6001, 2458.8000, 2459.8000,
    2460.8000, 2461.8000, 2462.8000, 2463.8000, 2465.0000, 2466.0000, 2467.0000, 2468.2000,
    2469.3999, 2470.6001, 2471.8000, 2472.8000, 2473.8000, 2475.0000, 2476.0000, 2477.0000,
    2478.0000, 2479.2000, 2480.3999, 2481.3999, 2482.2000, 2483.2000, 2484.2000, 2485.2000,
    2486.0000, 2487.0000, 2488.0000, 2489.0000, 2490.0000, 2491.0000, 2492.0000, 2493.0000,
    2494.0000, 2495.0000, 2496.0000, 2497.0000, 2498.3999, 2499.8000, 2501.0000, 2502.0000,
    2503.6001, 2504.8000, 2506.0000, 2507.3999, 2508.8000, 2510.0000, 2511.0000, 2512.3999,
    2513.6001, 2514.8000, 2515.8000, 2516.8000, 2517.8000, 2519.0000, 2520.0000, 2521.0000,
    2522.0000, 2523.2000, 2524.3999, 2525.6001, 2526.8000, 2527.8000, 2528.8000, 2529.8000,
    2531.0000, 2532.0000, 2533.0000, 2534.2000, 2535.3999, 2536.6001, 2537.8000, 2538.8000,
    2539.8000, 2541.0000, 2542.0000, 2543.0000, 2544.2000, 2545.0000, 2546.0000, 2547.0000,
    2548.0000, 2549.0000, 2549.8000, 2550.8000, 2551.8000, 2552.8000, 2553.8000, 2554.6001,
    2555.3999, 2556.3999, 2557.2000, 2558.0000, 2559.0000, 2560.0000, 2561.3999, 2562.8000,
    2564.0000, 2565.3999, 2566.8000, 2568.0000, 2569.6001, 2570.8000, 2572.0000, 2573.6001,
    2574.8000, 2576.0000, 2577.0000, 2578.0000, 2579.0000, 2580.0000, 2581.0000, 2582.0000,
    2583.0000, 2584.0000, 2585.0000, 2586.0000, 2587.0000, 2587.8000, 2588.8000, 2589.8000,
    2590.8000, 2591.8000, 2592.8000, 2594.0000, 2595.0000, 2596.2000, 2597.3999, 2598.8000,
    2599.8000, 2600.8000, 2602.0000, 2603.0000, 2604.2000, 2605.3999, 2606.6001, 2607.8000,
    2609.0000, 2610.0000, 2611.8000, 2612.8000, 2614.0000, 2615.6001, 2616.8000, 2618.0000,
    2619.6001, 2620.8000, 2622.0000, 2623.6001, 2624.8000, 2625.8000, 2626.8000, 2627.8000,
    2628.8000, 2629.8000, 2630.8000, 2631.8000, 2632.8000, 2633.8000, 2634.6001, 2635.6001,
    2636.6001, 2637.6001, 2638.6001, 2639.6001, 2640.8000, 2641.8000, 2643.0000, 2644.0000,
    2645.6001, 2646.8000, 2648.0000, 2649.0000, 2650.2000, 2651.8000, 2652.8000, 2654.0000,
    2655.0000, 2656.3999, 2657.3999, 2658.3999, 2659.3999, 2660.3999, 2661.3999, 2662.2000,
    2663.2000, 2664.2000, 2665.2000, 2666.2000, 2667.2000, 2668.2000, 2669.2000, 2670.0000,
    2671.0000, 2672.0000, 2673.3999, 2674.8000, 2675.8000, 2677.0000, 2678.0000, 2679.2000,
    2680.6001, 2681.8000, 2682.8000, 2684.0000, 2685.0000, 2686.3999, 2687.8000, 2688.8000,
    2689.8000, 2690.8000, 2692.0000, 2693.0000, 2694.0000, 2695.0000, 2696.2000, 2697.3999,
    2698.6001, 2699.8000, 2700.8000, 2701.8000, 2702.8000, 2704.0000, 2705.0000, 2706.0000,
    2707.0000, 2708.0000, 2709.0000, 2710.0000, 2711.0000, 2712.0000, 2713.0000, 2714.0000,
    2715.0000, 2716.0000, 2717.0000, 2718.2000, 2719.2000, 2720.2000, 2721.3999, 2722.8000,
    2723.8000, 2724.8000, 2726.0000, 2727.0000, 2728.0000, 2729.2000, 2730.3999, 2731.6001,
    2732.8000, 2733.8000, 2734.8000, 2736.0000, 2737.0000, 2738.0000, 2739.0000, 2740.0000,
    2741.0000, 2742.0000, 2743.2000, 2744.2000, 2745.3999, 2746.3999, 2747.3999, 2748.6001,
    2749.6001, 2750.8000, 2751.8000, 2752.8000, 2754.0000, 2755.0000, 2756.6001, 2757.8000,
    2759.0000, 2760.0000, 2761.6001, 2762.8000, 2764.0000, 2765.2000, 2766.6001, 2767.8000,
    2768.8000, 2769.8000, 2770.8000, 2771.8000, 2772.8000, 2774.0000, 2775.0000, 2776.0000,
    2777.0000, 2778.0000, 2779.0000, 2780.0000, 2781.0000, 2782.0000, 2783.0000, 2784.0000,
    2785.0000, 2786.6001, 2787.8000, 2789.0000, 2790.2000, 2791.6001, 2792.8000, 2794.0000,
    2795.2000, 2796.8000, 2797.8000, 2799.0000, 2800.6001, 2802.0000, 2803.8000, 2805.0000,
    2806.8000, 2808.2000, 2810.0000, 2811.6001, 2813.0000, 2814.8000, 2816.0000, 2816.8000,
    2817.6001, 2818.0000, 2818.8000, 2819.2000, 2820.0000, 2820.8000, 2821.0000, 2821.8000,
    2822.6001, 2823.0000, 2823.8000, 2824.2000, 2825.0000, 2825.8000, 2826.0000, 2826.8000,
    2827.6001, 2828.0000, 2828.8000, 2829.2000, 2830.0000, 2830.8000, 2831.0000, 2831.8000,
    2832.8000, 2833.8000, 2834.8000, 2835.8000, 2836.8000, 2837.8000, 2838.8000, 2839.8000,
    2840.8000, 2841.8000, 2842.8000, 2843.8000, 2844.8000, 2845.8000, 2846.8000, 2847.8000,
    2849.0000, 2850.2000, 2851.6001, 2852.8000, 2854.0000, 2855.3999, 2856.8000, 2858.0000,
    2859.0000, 2860.6001, 2861.8000, 2863.0000, 2864.2000, 2865.3999, 2866.6001, 2867.8000,
    2868.8000, 2869.8000, 2871.0000, 2872.0000, 2873.0000, 2874.2000, 2875.3999, 2876.6001,
    2877.8000, 2878.8000, 2879.8000, 2881.0000, 2882.0000, 2883.2000, 2884.6001, 2885.8000,
    2886.8000, 2888.0000, 2889.0000, 2890.2000, 2891.6001, 2892.8000, 2893.8000, 2895.0000,
    2896.0000, 2897.0000, 2898.0000, 2898.8000, 2899.8000, 2900.8000, 2901.8000, 2902.6001,
    2903.3999, 2904.2000, 2905.0000, 2906.0000, 2907.0000, 2908.0000, 2908.8000, 2909.8000,
    2910.8000, 2911.8000, 2912.8000, 2913.8000, 2915.0000, 2916.3999, 2917.8000, 2919.0000,
    2920.0000, 2921.3999, 2922.8000, 2924.0000, 2925.0000, 2926.6001, 2927.8000, 2928.8000,
    2929.8000, 2930.8000, 2931.8000, 2932.8000, 2933.8000, 2934.8000, 2935.8000, 2936.8000,
    2937.8000, 2938.8000, 2939.8000, 2940.8000, 2941.6001, 2942.6001, 2943.6001, 2944.6001,
    2945.8000, 2946.8000, 2948.0000, 2949.0000, 2950.2000, 2951.3999, 2952.8000, 2953.8000,
    2954.8000, 2956.0000, 2957.0000, 2958.2000, 2959.6001, 2960.8000, 2961.8000, 2962.8000,
    2963.8000, 2965.0000, 2966.0000, 2967.0000, 2968.0000, 2969.0000, 2970.2000, 2971.3999,
    2972.6001, 2973.8000, 2974.8000, 2975.8000, 2976.8000, 2977.8000, 2978.8000, 2980.0000,
    2981.0000, 2982.0000, 2983.0000, 2984.0000, 2985.0000, 2986.0000, 2987.2000, 2988.2000,
    2989.3999, 2990.3999, 2991.6001, 2992.8000, 2993.8000, 2994.8000, 2995.8000, 2996.8000,
    2998.0000, 2999.0000, 3000.0000, 3001.0000, 3002.2000, 3003.3999, 3004.6001, 3005.6001,
    3006.8000, 3007.8000, 3008.8000, 3010.0000, 3011.0000, 3012.2000, 3013.3999, 3014.6001,
    3015.8000, 3016.8000, 3018.0000, 3019.0000, 3020.2000, 3021.3999, 3022.8000, 3023.8000,
    3024.8000, 3026.0000, 3027.0000, 3028.0000, 3029.0000, 3030.2000, 3031.3999, 3032.6001,
    3033.8000, 3034.8000, 3035.8000, 3037.0000, 3038.0000, 3039.0000, 3040.0000, 3041.0000,
    3042.0000, 3043.0000, 3044.0000, 3045.0000, 3046.0000, 3047.0000, 3048.0000, 3049.0000,
    3050.0000, 3051.0000, 3051.8000, 3052.8000, 3053.8000, 3054.8000, 3055.8000, 3056.6001,
    3057.0000, 3057.8000, 3058.0000, 3058.8000, 3059.3999, 3060.0000, 3060.6001, 3061.0000,
    3061.8000, 3062.0000, 3062.8000, 3063.3999, 3064.0000, 3064.6001, 3065.0000, 3065.8000,
    3066.0000, 3066.8000, 3067.3999, 3068.0000, 3068.6001, 3069.0000, 3069.8000, 3070.0000,
    3070.8000, 3071.3999, 3072.0000, 3072.8000, 3074.0000, 3075.0000, 3076.0000, 3077.0000,
    3078.2000, 3079.3999, 3080.6001, 3081.8000, 3082.8000, 3083.8000, 3084.8000, 3086.0000,
    3087.0000, 3088.0000, 3089.0000, 3090.0000, 3091.2000, 3092.2000, 3093.3999, 3094.3999,
    3095.6001, 3096.6001, 3097.8000, 3098.8000, 3099.8000, 3100.8000, 3101.8000, 3102.8000,
    3103.8000, 3105.0000, 3106.0000, 3107.0000, 3108.0000, 3109.0000, 3110.0000, 3111.0000,
    3112.2000, 3113.2000, 3114.3999, 3115.6001, 3116.6001, 3117.8000, 3118.8000, 3119.8000,
    3120.8000, 3121.8000, 3123.0000, 3124.0000, 3125.0000, 3126.0000, 3127.2000, 3128.3999,
    3129.6001, 3130.8000, 3131.8000, 3132.8000, 3133.8000, 3135.0000, 3136.0000, 3137.0000,
    3138.0000, 3138.8000, 3139.8000, 3140.8000, 3141.8000, 3142.8000, 3143.6001, 3144.6001,
    3145.3999, 3146.3999, 3147.2000, 3148.0000, 3149.0000, 3150.0000, 3151.0000, 3152.0000,
    3153.0000, 3154.0000, 3155.3999, 3156.6001, 3157.8000, 3158.8000, 3160.0000, 3161.0000,
    3162.0000, 3163.3999, 3164.6001, 3165.8000, 3166.8000, 3168.0000, 3169.0000, 3170.0000,
    3171.0000, 3171.8000, 3172.8000, 3173.8000, 3174.8000, 3175.8000, 3176.8000, 3177.8000,
    3178.8000, 3179.8000, 3180.8000, 3181.8000, 3182.8000, 3183.8000, 3184.8000, 3185.8000,
    3187.0000, 3188.0000, 3189.2000, 3190.6001, 3191.8000, 3192.8000, 3194.0000, 3195.0000,
    3196.3999, 3197.8000, 3198.8000, 3200.0000, 3200.8000, 3201.8000, 3202.8000, 3203.8000,
    3204.6001, 3205.3999, 3206.2000, 3207.0000, 3208.0000, 3209.0000, 3209.8000, 3210.8000,
    3211.8000, 3212.6001, 3213.3999, 3214.2000, 3215.0000, 3216.0000, 3217.0000, 3218.0000,
    3218.8000, 3219.8000, 3220.8000, 3221.8000, 3222.6001, 3223.3999, 3224.2000, 3225.0000,
    3226.0000, 3227.0000, 3228.0000, 3229.0000, 3229.8000, 3230.8000, 3231.8000, 3232.8000,
    3234.0000, 3235.2000, 3236.8000, 3238.0000, 3239.2000, 3240.8000, 3241.8000, 3243.0000,
    3244.6001, 3245.8000, 3247.0000, 3248.3999, 3249.8000, 3251.0000, 3252.3999, 3253.8000,
    3255.0000, 3256.2000, 3257.8000, 3259.0000, 3260.2000, 3261.8000, 3263.0000, 3264.0000,
    3265.0000, 3265.8000, 3266.8000, 3267.6001, 3268.2000, 3269.0000, 3270.0000, 3270.8000,
    3271.6001, 3272.3999, 3273.0000, 3274.0000, 3274.8000, 3275.8000, 3276.3999, 3277.0000,
    3278.0000, 3278.8000, 3279.8000, 3280.6001, 3281.3999, 3282.2000, 3283.0000, 3284.0000,
    3285.0000, 3286.0000, 3286.8000, 3287.8000, 3288.8000, 3289.6001, 3290.3999, 3291.2000,
    3292.0000, 3293.0000, 3294.0000, 3295.0000, 3295.8000, 3296.8000, 3298.0000, 3299.0000,
    3300.0000, 3301.2000, 3302.3999, 3303.6001, 3304.8000, 3305.8000, 3306.8000, 3308.0000,
    3309.0000, 3310.0000, 3311.2000, 3312.3999, 3313.0000, 3314.0000, 3315.0000, 3315.8000,
    3316.8000, 3317.6001, 3318.3999, 3319.0000, 3320.0000, 3321.0000, 3321.8000, 3322.8000,
    3323.6001, 3324.2000, 3325.0000, 3326.0000, 3327.0000, 3327.8000, 3328.8000, 3329.3999,
    3330.0000, 3330.8000, 3331.8000, 3332.3999, 3333.0000, 3334.0000, 3334.8000, 3335.6001,
    3336.2000, 3337.0000, 3337.8000, 3338.8000, 3339.3999, 3340.0000, 3341.0000, 3341.8000,
    3342.6001, 3343.0000, 3344.0000, 3344.8000, 3345.8000, 3346.8000, 3347.6001, 3348.2000,
    3349.0000, 3350.0000, 3351.0000, 3351.8000, 3352.8000, 3353.6001, 3354.3999, 3355.2000,
    3356.0000, 3357.0000, 3357.8000, 3358.8000, 3359.8000, 3360.6001, 3361.8000, 3362.8000,
    3363.8000, 3364.8000, 3365.8000, 3366.8000, 3367.8000, 3369.0000, 3370.0000, 3371.0000,
    3372.0000, 3373.0000, 3374.0000, 3375.2000, 3376.2000, 3377.3999, 3378.3999, 3379.3999,
    3380.3999, 3381.6001, 3382.6001, 3383.6001, 3384.8000, 3385.8000, 3386.8000, 3387.8000,
    3388.8000, 3389.8000, 3390.8000, 3391.8000, 3392.8000, 3394.0000, 3395.0000, 3396.0000,
    3397.0000, 3398.2000, 3399.2000, 3400.3999, 3401.6001, 3402.8000, 3403.8000, 3404.8000,
    3405.8000, 3407.0000, 3408.0000, 3408.8000, 3409.8000, 3410.2000, 3411.0000, 3411.8000,
    3412.8000, 3413.2000, 3414.0000, 3414.8000, 3415.8000, 3416.2000, 3417.0000, 3417.8000,
    3418.8000, 3419.2000, 3420.0000, 3420.8000, 3421.8000, 3422.3999, 3423.0000, 3423.8000,
    3424.8000, 3425.8000, 3426.8000, 3427.8000, 3428.8000, 3429.8000, 3430.6001, 3431.6001,
    3432.6001, 3433.3999, 3434.3999, 3435.3999, 3436.2000, 3437.2000, 3438.2000, 3439.0000,
    3440.0000, 3441.2000, 3442.3999, 3443.8000, 3444.8000, 3445.8000, 3447.0000, 3448.0000,
    3449.0000, 3450.0000, 3451.3999, 3452.6001, 3453.8000, 3454.8000, 3455.8000, 3456.8000,
    3457.3999, 3458.0000, 3459.0000, 3459.8000, 3460.6001, 3461.0000, 3462.0000, 3462.8000,
    3463.6001, 3464.0000, 3465.0000, 3465.8000, 3466.6001, 3467.0000, 3468.0000, 3468.8000,
    3469.6001, 3470.0000, 3471.0000, 3471.8000, 3472.8000, 3473.6001, 3474.3999, 3475.2000,
    3476.0000, 3477.0000, 3478.0000, 3478.8000, 3479.8000, 3480.8000, 3481.8000, 3482.6001,
    3483.3999, 3484.2000, 3485.0000, 3486.0000, 3487.0000, 3487.8000, 3488.8000, 3489.8000,
    3490.8000, 3491.8000, 3492.6001, 3493.6001, 3494.3999, 3495.3999, 3496.2000, 3497.0000,
    3498.0000, 3499.0000, 3500.0000, 3501.0000, 3502.0000, 3502.8000, 3503.8000, 3504.8000,
    3505.6001, 3506.2000, 3507.0000, 3507.8000, 3508.8000, 3509.6001, 3510.2000, 3511.0000,
    3512.0000, 3512.8000, 3513.6001, 3514.2000, 3515.0000, 3516.0000, 3516.8000, 3517.6001,
    3518.2000, 3519.0000, 3520.0000, 3520.8000, 3521.6001, 3522.0000, 3523.0000, 3523.8000,
    3524.6001, 3525.0000, 3526.0000, 3526.8000, 3527.6001, 3528.0000, 3529.0000, 3529.8000,
    3530.6001, 3531.0000, 3532.0000, 3532.8000, 3533.6001, 3534.0000, 3535.0000, 3535.8000,
    3536.8000, 3537.8000, 3538.8000, 3539.8000, 3540.8000, 3541.8000, 3543.0000, 3544.0000,
    3545.0000, 3546.0000, 3547.0000, 3548.0000, 3549.0000, 3550.2000, 3551.3999, 3552.3999,
    3553.0000, 3554.0000, 3554.8000, 3555.8000, 3556.3999, 3557.0000, 3558.0000, 3558.8000,
    3559.8000, 3560.6001, 3561.2000, 3562.0000, 3562.8000, 3563.8000, 3564.6001, 3565.2000,
    3566.0000, 3567.0000, 3567.8000, 3568.6001, 3569.0000, 3570.0000, 3570.8000, 3571.3999,
    3572.0000, 3572.8000, 3573.6001, 3574.0000, 3575.0000, 3575.8000, 3576.3999, 3577.0000,
    3577.8000, 3578.6001, 3579.0000, 3580.0000, 3580.8000, 3581.3999, 3582.0000, 3582.8000,
    3583.6001, 3584.0000, 3585.0000, 3585.8000, 3586.8000, 3587.6001, 3588.2000, 3589.0000,
    3590.0000, 3590.8000, 3591.6001, 3592.2000, 3593.0000, 3594.0000, 3594.8000, 3595.8000,
    3596.3999, 3597.0000, 3598.0000, 3598.8000, 3599.8000, 3600.6001, 3601.2000, 3602.0000,
    3603.0000, 3603.8000, 3604.8000, 3605.3999, 3606.0000, 3607.0000, 3607.8000, 3608.8000,
    3609.6001, 3610.2000, 3611.0000, 3612.0000, 3612.8000, 3613.8000, 3614.3999, 3615.2000,
    3616.0000, 3617.0000, 3617.8000, 3618.8000, 3619.3999, 3620.0000, 3621.0000, 3621.8000,
    3622.8000, 3623.6001, 3624.3999, 3625.0000, 3626.0000, 3626.8000, 3627.8000, 3628.6001,
    3629.2000, 3630.0000, 3631.0000, 3631.8000, 3632.8000, 3633.3999, 3634.0000, 3635.0000,
    3635.8000, 3636.8000, 3637.3999, 3638.0000, 3639.0000, 3639.8000, 3640.8000, 3641.2000,
    3642.0000, 3643.0000, 3643.8000, 3644.6001, 3645.2000, 3646.0000, 3646.8000, 3647.8000,
    3648.3999, 3649.0000, 3649.8000, 3650.6001, 3651.0000, 3651.8000, 3652.8000, 3653.2000,
    3654.0000, 3654.8000, 3655.2000, 3656.0000, 3656.8000, 3657.3999, 3658.0000, 3658.8000,
    3659.3999, 3660.0000, 3660.8000, 3661.6001, 3662.0000, 3662.8000, 3663.8000, 3664.2000,
    3665.0000, 3665.8000, 3666.6001, 3667.2000, 3668.0000, 3668.8000, 3669.6001, 3670.2000,
    3671.0000, 3671.8000, 3672.6001, 3673.2000, 3674.0000, 3674.8000, 3675.6001, 3676.2000,
    3677.0000, 3677.8000, 3678.6001, 3679.2000, 3680.0000, 3680.8000, 3681.6001, 3682.0000,
    3683.0000, 3683.8000, 3684.3999, 3685.0000, 3685.8000, 3686.8000, 3687.2000, 3688.0000,
    3688.8000, 3689.6001, 3690.0000, 3691.0000, 3691.8000, 3692.3999, 3693.0000, 3693.8000,
    3694.8000, 3695.2000, 3696.0000, 3697.0000, 3697.8000, 3698.8000, 3699.8000, 3700.6001,
    3701.2000, 3702.0000, 3703.0000, 3704.0000, 3704.8000, 3705.8000, 3706.8000, 3707.6001,
    3708.3999, 3709.2000, 3710.0000, 3711.0000, 3712.0000, 3712.8000, 3713.3999, 3714.0000,
    3714.8000, 3715.8000, 3716.2000, 3717.0000, 3717.8000, 3718.6001, 3719.0000, 3720.0000,
    3720.8000, 3721.3999, 3722.0000, 3722.8000, 3723.8000, 3724.2000, 3725.0000, 3725.8000,
    3726.6001, 3727.0000, 3728.0000, 3728.8000, 3729.0000, 3729.8000, 3730.6001, 3731.0000,
    3731.8000, 3732.3999, 3733.0000, 3733.8000, 3734.2000, 3735.0000, 3735.8000, 3736.2000,
    3737.0000, 3737.6001, 3738.0000, 3738.8000, 3739.6001, 3740.0000, 3740.8000, 3741.3999,
    3742.0000, 3742.8000, 3743.2000, 3744.0000, 3744.6001, 3745.0000, 3745.8000, 3746.0000,
    3746.8000, 3747.2000, 3747.8000, 3748.3999, 3749.0000, 3749.8000, 3750.0000, 3750.8000,
    3751.0000, 3751.8000, 3752.2000, 3753.0000, 3753.6001, 3754.0000, 3754.8000, 3755.0000,
    3755.8000, 3756.2000, 3756.8000, 3757.3999, 3758.0000, 3758.6001, 3759.0000, 3759.8000,
    3760.0000, 3760.8000, 3761.8000, 3762.2000, 3763.0000, 3763.8000, 3764.6001, 3765.0000,
    3766.0000, 3766.8000, 3767.3999, 3768.0000, 3768.8000, 3769.6001, 3770.0000, 3771.0000,
    3771.8000, 3772.3999, 3773.0000, 3773.8000, 3774.8000, 3775.2000, 3776.0000, 3776.8000,
    3777.2000, 3778.0000, 3778.8000, 3779.0000, 3779.8000, 3780.6001, 3781.0000, 3781.8000,
    3782.3999, 3783.0000, 3783.8000, 3784.2000, 3785.0000, 3785.6001, 3786.0000, 3786.8000,
    3787.3999, 3788.0000, 3788.8000, 3789.2000, 3790.0000, 3790.8000, 3791.0000, 3791.8000,
    3792.6001, 3793.0000, 3793.8000, 3794.3999, 3795.0000, 3795.8000, 3796.2000, 3797.0000,
    3797.8000, 3798.0000, 3798.8000, 3799.6001, 3800.0000, 3800.8000, 3801.3999, 3802.0000,
    3802.8000, 3803.2000, 3804.0000, 3804.8000, 3805.0000, 3805.8000, 3806.6001, 3807.0000,
    3807.8000, 3808.3999, 3809.0000, 3810.0000, 3810.8000, 3811.8000, 3812.6001, 3813.2000,
    3814.0000, 3814.8000, 3815.8000, 3816.6001, 3817.2000, 3818.0000, 3819.0000, 3819.8000,
    3820.6001, 3821.2000, 3822.0000, 3823.0000, 3823.8000, 3824.6001, 3825.0000, 3825.8000,
    3826.2000, 3827.0000, 3827.6001, 3828.0000, 3828.8000, 3829.3999, 3830.0000, 3830.8000,
    3831.0000, 3831.8000, 3832.6001, 3833.0000, 3833.8000, 3834.2000, 3835.0000, 3835.6001,
    3836.0000, 3836.8000, 3837.3999, 3838.0000, 3838.8000, 3839.0000, 3839.8000, 3840.6001,
    3841.0000, 3841.8000, 3842.6001, 3843.0000, 3843.8000, 3844.6001, 3845.0000, 3845.8000,
    3846.6001, 3847.0000, 3847.8000, 3848.6001, 3849.0000, 3849.8000, 3850.6001, 3851.0000,
    3851.8000, 3852.3999, 3853.0000, 3853.8000, 3854.3999, 3855.0000, 3855.8000, 3856.3999,
    3857.0000, 3857.8000, 3858.6001, 3859.0000, 3859.8000, 3860.6001, 3861.0000, 3861.8000,
    3862.8000, 3863.0000, 3864.0000, 3864.8000, 3865.2000, 3866.0000, 3866.8000, 3867.2000,
    3868.0000, 3868.8000, 3869.2000, 3870.0000, 3870.8000, 3871.3999, 3872.0000, 3872.8000,
    3873.2000, 3873.8000, 3874.6001, 3875.0000, 3875.8000, 3876.2000, 3877.0000, 3877.6001,
    3878.0000, 3878.8000, 3879.3999, 3880.0000, 3880.8000, 3881.0000, 3881.8000, 3882.3999,
    3883.0000, 3883.8000, 3884.0000, 3884.8000, 3885.3999, 3886.0000, 3886.8000, 3887.2000,
    3887.8000, 3888.6001, 3889.0000, 3889.8000, 3890.0000, 3890.8000, 3891.3999, 3892.0000,
    3892.6001, 3893.0000, 3893.8000, 3894.0000, 3894.8000, 3895.3999, 3896.0000, 3896.8000,
    3897.0000, 3897.8000, 3898.2000, 3898.8000, 3899.6001, 3900.0000, 3900.8000, 3901.0000,
    3901.8000, 3902.2000, 3903.0000, 3903.6001, 3904.0000, 3904.8000, 3905.3999, 3906.0000,
    3906.8000, 3907.6001, 3908.0000, 3908.8000, 3909.8000, 3910.2000, 3911.0000, 3911.8000,
    3912.3999, 3913.0000, 3913.8000, 3914.3999, 3915.0000, 3915.8000, 3916.6001, 3917.0000,
    3917.8000, 3918.8000, 3919.2000, 3920.0000, 3920.8000, 3921.2000, 3921.8000, 3922.6001,
    3923.0000, 3923.8000, 3924.3999, 3925.0000, 3925.8000, 3926.2000, 3927.0000, 3927.8000,
    3928.2000, 3928.8000, 3929.6001, 3930.0000, 3930.8000, 3931.3999, 3932.0000, 3932.8000,
    3933.3999, 3934.0000, 3934.8000, 3935.2000, 3935.8000, 3936.6001, 3937.0000, 3937.6001,
    3938.0000, 3938.6001, 3939.0000, 3939.8000, 3940.0000, 3940.8000, 3941.0000, 3941.8000,
    3942.0000, 3942.8000, 3943.0000, 3943.8000, 3944.2000, 3944.8000, 3945.2000, 3945.8000,
    3946.2000, 3946.8000, 3947.3999, 3947.8000, 3948.3999, 3949.0000, 3949.6001, 3950.0000,
    3950.6001, 3951.0000, 3951.6001, 3952.0000, 3952.8000, 3953.6001, 3954.2000, 3955.0000,
    3955.8000, 3956.8000, 3957.3999, 3958.0000, 3959.0000, 3959.8000, 3960.6001, 3961.2000,
    3962.0000, 3962.8000, 3963.8000, 3964.3999, 3965.0000, 3966.0000, 3966.8000, 3967.6001,
    3968.0000, 3968.8000, 3969.2000, 3969.8000, 3970.2000, 3970.8000, 3971.2000, 3971.8000,
    3972.3999, 3973.0000, 3973.3999, 3974.0000, 3974.6001, 3975.0000, 3975.6001, 3976.0000,
    3976.6001, 3977.0000, 3977.8000, 3978.0000, 3978.8000, 3979.0000, 3979.8000, 3980.0000,
    3980.8000, 3981.0000, 3981.8000, 3982.2000, 3982.8000, 3983.2000, 3983.8000, 3984.3999,
    3985.0000, 3985.6001, 3986.0000, 3986.8000, 3987.0000, 3987.8000, 3988.0000, 3988.8000,
    3989.2000, 3989.8000, 3990.3999, 3991.0000, 3991.6001, 3992.0000, 3992.8000, 3993.0000,
    3993.8000, 3994.2000, 3994.8000, 3995.3999, 3996.0000, 3996.6001, 3997.0000, 3997.8000,
    3998.0000, 3998.8000, 3999.2000, 3999.8000, 4000.3999, 4001.0000, 4001.8000, 4002.8000,
    4003.2000, 4004.0000, 4004.8000, 4005.6001, 4006.2000, 4007.0000, 4007.8000, 4008.6001,
    4009.0000, 4010.0000, 4010.8000, 4011.3999, 4012.0000, 4012.8000, 4013.8000, 4014.2000,
    4015.0000, 4015.8000, 4016.3999, 4017.0000, 4017.6001, 4018.0000, 4018.6001, 4019.0000,
    4019.8000, 4020.0000, 4020.8000, 4021.0000, 4021.8000, 4022.0000, 4022.8000, 4023.2000,
    4023.8000, 4024.2000, 4024.8000, 4025.3999, 4026.0000, 4026.3999, 4027.0000, 4027.6001,
    4028.0000, 4028.6001, 4029.0000, 4029.8000, 4030.0000, 4030.8000, 4031.0000, 4031.8000,
    4032.0000, 4032.8000, 4033.2000, 4033.8000, 4034.2000, 4034.8000, 4035.2000, 4035.8000,
    4036.3999, 4036.8000, 4037.3999, 4038.0000, 4038.3999, 4039.0000, 4039.6001, 4040.0000,
    4040.6001, 4041.0000, 4041.6001, 4042.0000, 4042.8000, 4043.0000, 4043.8000, 4044.0000,
    4044.8000, 4045.0000, 4045.8000, 4046.0000, 4046.8000, 4047.2000, 4047.8000, 4048.2000,
    4049.0000, 4049.8000, 4050.3999, 4051.0000, 4051.8000, 4052.8000, 4053.2000, 4054.0000,
    4054.8000, 4055.3999, 4056.0000, 4056.8000, 4057.6001, 4058.0000, 4058.8000, 4059.8000,
    4060.2000, 4061.0000, 4061.8000, 4062.3999, 4063.0000, 4063.8000, 4066.8000, 4072.0000,
    4077.60,
];
}

Необязательно: калибровка АЦП для вашего ESP32

Если хотите сгенерировать LUT для своего ESP32, можно попробовать такой подход:

1. Скачайте программу калибровки

Клонируйте репозиторий калибровки:

git clone https://github.com/e-tinkers/esp32-adc-calibrate.git
cd esp32-adc-calibrate

Репозиторий содержит проект PlatformIO, который можно загрузить в оригинальный ESP32.

2. Соедините ЦАП с АЦП

Для оригинального ESP32, который используется в DevKit V1, соедините:

GPIO25 (DAC) ---- GPIO35 (ADC)

Для этого соединения используйте провод-перемычку.

3. Соберите и загрузите программу

Сначала установите uv по официальной инструкции:

curl -LsSf https://astral.sh/uv/install.sh | sh

Проверьте установку:

uv --version

Затем соберите проект:

uvx platformio run

Загрузите его в ESP32:

uvx platformio run -t upload

uvx запускает PlatformIO в изолированном окружении, поэтому не нужно устанавливать PlatformIO в систему.

4. Откройте монитор порта

Программа калибровки использует скорость 500000 бод:

uvx platformio device monitor -b 500000

Программа выведет сгенерированную LUT в монитор порта.

5. Скопируйте сгенерированную LUT

Скопируйте полную LUT, которую напечатала программа калибровки, и замените ею LUT в вашей программе на Rust.

Нелинейность термистора

У термисторов связь между сопротивлением и температурой нелинейна: при изменении температуры сопротивление меняется не по прямой линии. Поведение термисторов можно описать с помощью уравнения Стейнхарта — Харта или B-уравнения.

нелинейность термистора

B-уравнение просто рассчитать, используя B-значение, которое можно найти в даташите термистора или у производителя. Уравнение Стейнхарта — Харта использует коэффициенты A, B и C. Некоторые производители предоставляют эти коэффициенты для своих термисторов. Когда подходящие коэффициенты доступны, уравнение Стейнхарта — Харта может дать более точные расчёты температуры в более широком диапазоне.

В следующих главах мы подробно разберём, как использовать B-уравнение и уравнение Стейнхарта — Харта для определения температуры.

Ссылки

B-уравнение

B-уравнение проще, но менее точно. \[ \frac{1}{T} = \frac{1}{T_0} + \frac{1}{B} \ln \left( \frac{R}{R_0} \right) \]

Где:

  • T — температура в кельвинах.
  • \( T_0 \) — опорная температура (обычно 298,15 K, или 25°C), при которой известно сопротивление термистора (обычно 10 кОм).
  • R — сопротивление при температуре T.
  • \( R_0 \) — сопротивление при опорной температуре \( T_0 \) (часто 10 кОм).
  • B — B-значение термистора.

B-значение — это константа, которую обычно указывает производитель. Оно зависит от материала и конструкции термистора и описывает, как меняется его сопротивление с температурой на заданном диапазоне. Вы также можете вычислить B-значение сами, измерив сопротивление термистора при двух известных температурах.

Пример расчёта:

Дано:

  • Опорная температура \( T_0 = 298.15K \) (то есть 25°C + 273,15 для перевода в кельвины)
  • Опорное сопротивление \( R_0 = 10k\Omega \)
  • B-значение B = 3950 (типично для многих термисторов)
  • Измеренное сопротивление при температуре T: 10475 Ом

Шаг 1: применяем B-уравнение

Подставляем заданные значения:

\[ \frac{1}{T} = \frac{1}{298.15} + \frac{1}{3950} \ln \left( \frac{10,475}{10,000} \right) \]

\[ \frac{1}{T} = 0.003354016 + \frac{1}{3950} \ln(1.0475) \]

\[ \frac{1}{T} = 0.003354016 + (0.000011748) \]

\[ \frac{1}{T} = 0.003365764 \]

Шаг 2: вычисляем температуру (T)

\[ T = \frac{1}{0.003365764} = 297.10936358 (Kelvin) \]

Переведём в градусы Цельсия:

\[ T_{Celsius} = 297.10936358 - 273.15 \approx 23.95936358°C \]

Результат:

Температура, соответствующая сопротивлению 10475 Ом, примерно равна 23,96°C.

Функция на Rust

const fn kelvin_to_celsius(kelvin: f64) -> f64 {
    kelvin - 273.15
}

const fn celsius_to_kelvin(celsius: f64) -> f64 {
    celsius + 273.15
}

const REF_RES: f64 = 10_000.0; // Опорное сопротивление в омах (10 кОм)
const REF_TEMP: f64 = 25.0;  // Опорная температура 25°C
const REF_TEMP_K: f64 = celsius_to_kelvin(REF_TEMP); // T0

fn calculate_temperature(current_res: f64, b_val: f64) -> f64 {
    let ln_value = (current_res/REF_RES).ln();
    // let ln_value = libm::log(current_res / ref_res); // используйте этот крейт для no_std
    let inv_t = (1.0 / REF_TEMP_K) + ((1.0 / b_val) * ln_value);
    1.0 / inv_t
}


const B_VALUE: f64 = 3950.0;
const V_IN: f64 = 3.3; // Входное напряжение

fn main() {
    let r = 9546.0; // Измеренное сопротивление в омах
    
    let temperature_kelvin = calculate_temperature(r, B_VALUE);
    let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
    println!("Temperature: {:.2} °C", temperature_celsius);
}

Уравнение Стейнхарта — Харта

Tip

В нашем упражнении мы не будем использовать это уравнение, потому что найти константы A, B и C сложнее. B-уравнения достаточно для наших целей, так что, если хотите, можете пропустить эту главу.

Уравнение Стейнхарта — Харта даёт более точную зависимость между температурой и сопротивлением в широком диапазоне температур. \[ \frac{1}{T} = A + B \ln R + C (\ln R)^3 \]

Где:

  • T — температура в кельвинах. (Формула для перевода градусов Цельсия в кельвины: K = °C + 273.15)
  • R — сопротивление при температуре T в омах.
  • A, B и C — константы, специфичные для материала термистора; их часто указывает производитель. Для лучшей точности, возможно, придётся откалибровать эти значения и определить их самостоятельно. Некоторые даташиты приводят значения сопротивления при разных температурах, по ним тоже можно это рассчитать.

Калибровка

Чтобы определить точные значения A, B и C, поместите термистор в три температурных условия: комнатная температура, ледяная вода и горячая вода. В каждом случае измерьте сопротивление термистора по значению АЦП и с помощью надёжного термометра запишите фактическую температуру. По значениям сопротивления и соответствующим температурам вычислите коэффициенты:

  • A соответствует температуре ледяной воды,
  • B соответствует комнатной температуре, и
  • C соответствует температуре горячей воды.

Расчёт коэффициентов Стейнхарта — Харта

По трём точкам «сопротивление — температура» можно найти A, B и C.

$$ \begin{bmatrix} 1 & \ln R_1 & \ln^3 R_1 \\ 1 & \ln R_2 & \ln^3 R_2 \\ 1 & \ln R_3 & \ln^3 R_3 \end{bmatrix}\begin{bmatrix} A \\ B \\ C \end{bmatrix} = \begin{bmatrix} \frac{1}{T_1} \\ \frac{1}{T_2} \\ \frac{1}{T_3} \end{bmatrix} $$

Где:

  • \( R_1, R_2, R_3 \) — значения сопротивления при температурах \( T_1, T_2, T_3 \).

Посчитаем коэффициенты

Вычислим натуральные логарифмы сопротивлений: $$ L_1 = \ln R_1, \quad L_2 = \ln R_2, \quad L_3 = \ln R_3 $$

Промежуточные вычисления: $$ Y_1 = \frac{1}{T_1}, \quad Y_2 = \frac{1}{T_2}, \quad Y_3 = \frac{1}{T_3} $$

$$ \gamma_2 = \frac{Y_2 - Y_1}{L_2 - L_1}, \quad \gamma_3 = \frac{Y_3 - Y_1}{L_3 - L_1} $$

Итак, окончательно: $$ C = \left( \frac{ \gamma_3 - \gamma_2 }{ L_3 - L_2} \right) \left(L_1 + L_2 + L_3\right)^{-1} \ $$ $$ B = \gamma_2 - C \left(L_1^2 + L_1 L_2 + L_2^2\right) \ $$ $$ A = Y_1 - \left(B + L_1^2 C\right) L_1 $$

Хорошая новость! Не нужно вычислять коэффициенты вручную. Просто введите значения сопротивления и температуры для холодной, комнатной и горячей среды, а затем с помощью формы ниже определите A, B и C

Значение АЦП и расчёт сопротивления

Примечание: если у вас уже есть температура и соответствующее сопротивление, можно сразу ввести эти значения во вторую таблицу.

Если у вас есть значение АЦП и вы хотите вычислить сопротивление, используйте эту таблицу, чтобы найти соответствующее сопротивление при разных температурах. Когда вы вводите значение АЦП для каждой температуры, рассчитанное сопротивление автоматически обновится во второй таблице.

Для расчёта понадобится базовое сопротивление термистора: оно нужно, чтобы по значению АЦП определить сопротивление при заданной температуре.

Обратите внимание: разрядность АЦП может потребовать корректировки, если вы используете другой микроконтроллер. В нашем случае для ESP32 разрешение АЦП составляет 12 бит.




Среда Значение АЦП
Холодная вода
Комнатная температура
Горячая вода

Поиск коэффициентов

Задайте температуру, введя значение в градусах Фаренгейта или Цельсия: форма автоматически пересчитает его в другой формат. Укажите сопротивление для каждой температуры и нажмите кнопку «Вычислить коэффициенты».

Среда Сопротивление (Ом) Температура (°F) Температура (°C) Температура (K)
Холодная вода
Комнатная температура
Горячая вода

Результаты

A:

B:

C:

Расчёт температуры по сопротивлению

Теперь с помощью этих коэффициентов можно вычислить температуру для любого сопротивления:

Функция на Rust

fn steinhart_temp_calc(
    resistance: f64, // Сопротивление в омах
    a: f64,          // Коэффициент A
    b: f64,          // Коэффициент B
    c: f64,          // Коэффициент C
) -> Result<(f64, f64), String> {
    if resistance <= 0.0 {
        return Err("Resistance must be a positive number.".to_string());
    }

    // Вычисляем температуру в кельвинах по уравнению Стейнхарта — Харта:
    // 1/T = A + B*ln(R) + C*(ln(R))^3
    let ln_r = resistance.ln();
    let inverse_temperature = a + b * ln_r + c * ln_r.powi(3);

    if inverse_temperature == 0.0 {
        return Err("Invalid coefficients or resistance leading to division by zero.".to_string());
    }

    let temperature_kelvin = 1.0 / inverse_temperature;

    let temperature_celsius = temperature_kelvin - 273.15;
    let temperature_fahrenheit = (temperature_celsius * 9.0 / 5.0) + 32.0;

    Ok((temperature_celsius, temperature_fahrenheit))
}

fn main() {
    // Пример входных данных
     let a = 2.10850817e-3;
    let b = 7.97920473e-5;
    let c = 6.53507631e-7;
    let resistance = 10000.0;


    match steinhart_temp_calc(resistance, a, b, c) {
        Ok((celsius, fahrenheit)) => {
            println!("Temperature in Celsius: {:.2}", celsius);
            println!("Temperature in Fahrenheit: {:.2}", fahrenheit);
        }
        Err(e) => println!("Error: {}", e),
    }
}

Ссылки

Схема: подключение ESP32 к термистору

  1. Один вывод термистора подключён к GND.
  2. Другой вывод термистора подключён к GPIO13, который является выводом ADC2 на ESP32.
  3. Резистор 10 кОм подключается последовательно с термистором, образуя делитель напряжения между термистором и 3.3V. Поэтому один вывод резистора подключите к 3.3V, а другой — к GPIO13.
схема подключения ESP32 с термистором

Вывод значений АЦП

В этом упражнении мы считываем значение АЦП и с его помощью находим сопротивление термистора. Затем вычисляем температуру в градусах Цельсия. После этого выведем в системную консоль значение АЦП, сопротивление и температуру.

Note

Показания АЦП оригинального ESP32 не идеально точны, поэтому использование сырого значения АЦП напрямую может заметно исказить рассчитанные сопротивление и температуру. В наших тестах этот код показывал около 31°C, тогда как тот же термистор, измеренный реализацией для Arduino ESP32 с калибровкой АЦП, показывал около 26°C. Таблица LUT должна дать результат ближе к показаниям Arduino.

В последнее время в esp-hal добавили поддержку калиброванных показаний АЦП, что должно повысить точность этого расчёта. На момент написания книги эта калибровка доступна в версии esp-hal для разработки, но ещё не входит в выпущенную версию, которую мы используем. Мы обновим эту главу, когда калибровка войдёт в стабильный релиз.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 thermistor 

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features»
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

libm: libm — математическая библиотека для окружений no_std. Нам нужна из неё функция логарифма.

libm = "0.2.11"

Крейт nb:

nb = "1.1.0"

Вспомогательные функции для перевода между Цельсием и кельвинами

Создадим простые константные функции для перевода из градусов Цельсия в кельвины и обратно.

#![allow(unused)]
fn main() {
const fn kelvin_to_celsius(kelvin: f64) -> f64 {
    kelvin - 273.15
}

const fn celsius_to_kelvin(celsius: f64) -> f64 {
    celsius + 273.15
}
}

Константы

Так как у ESP32 12-битный АЦП, максимальное значение равно 4095.

#![allow(unused)]
fn main() {
const ADC_MAX: f64 = 4095.0; // 4095 для 12-битного АЦП
}

Типичное B-значение для термистора NTC 103 равно 3950. Обычно его указывают в описании товара при покупке. Если его там нет, можно попробовать значение 3950 для NTC 103. Если не сработает, возможно, понадобится откалибровать и определить правильное значение.

#![allow(unused)]
fn main() {
const B_VALUE: f64 = 3950.0;
}

Опорную температуру и соответствующее ей сопротивление обычно указывает производитель. Как правило, это 25°C и 10 кОм, то есть сопротивление термистора составляет примерно 10 кОм при 25°C.

Для B-уравнения нам нужна опорная температура в кельвинах. Поэтому переведём её и создадим константу REF_TEMP_K.

#![allow(unused)]
fn main() {
const REF_TEMP: f64 = 25.0; // Опорная температура 25°C
const REF_RES: f64 = 10_000.0; // Сопротивление термистора при опорной температуре (25°C)
const REF_TEMP_K: f64 = celsius_to_kelvin(REF_TEMP);
}

Нам нужно задать значение сопротивления R1 для расчёта сопротивления термистора (R2). Лучше взять резистор, номинал которого совпадает с опорным сопротивлением. Поэтому в делителе напряжения мы используем резистор 10 кОм последовательно с термистором. Я просто копирую значение REF_RES в R1_RES. Если хотите, можно прописать 10_000 напрямую: разницы почти нет.

#![allow(unused)]
fn main() {
const R1_RES: f64 = REF_RES; // 10_000.0 Ом
}

Расчёт сопротивления по значению АЦП

Мы объясняли эту формулу и её вывод в предыдущей главе. Теперь используем функцию, которая применяет формулу и переводит значение АЦП в сопротивление термистора.

#![allow(unused)]
fn main() {
fn adc_to_resistance(adc_value: f64) -> f64 {
    let x: f64 = adc_value / (ADC_MAX - adc_value);
    R1_RES * x
}
}

Расчёт температуры по сопротивлению

Когда у нас есть сопротивление термистора, можно применить B-уравнение с B-значением, чтобы вычислить температуру. Результат получится в кельвинах.

#![allow(unused)]
fn main() {
// B-уравнение для перевода сопротивления в температуру
fn calculate_temperature(current_res: f64, b_val: f64) -> f64 {
    let ln_value = libm::log(current_res / REF_RES); // Используем libm для `no_std`
    let inv_t = (1.0 / REF_TEMP_K) + ((1.0 / b_val) * ln_value);
    1.0 / inv_t
}
}

Настройка АЦП

В функции main мы инициализируем АЦП и настраиваем GPIO13 как вывод АЦП.

#![allow(unused)]
fn main() {
let adc_pin = peripherals.GPIO13;
let mut adc2_config = AdcConfig::new();
let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::_11dB);
let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);
}

Главный цикл

В главном цикле сначала считываем значение АЦП и выводим его. Затем с помощью таблицы поиска АЦП (LUT) исправляем нелинейность АЦП, о которой говорили в предыдущей главе, и выводим исправленное значение.

Исправленное значение АЦП используем, чтобы вычислить сопротивление термистора, а затем температуру. Так как результат будет в кельвинах, переводим его в градусы Цельсия и выводим.

#![allow(unused)]
fn main() {
loop {
    let adc_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
    esp_println::println!("ADC: {}", adc_value);
    let adc_value: f64 = ADC_LUT[adc_value as usize];
    esp_println::println!("Corrected ADC: {}", adc_value);

    let current_res = adc_to_resistance(adc_value);
    esp_println::println!("R2: {}", current_res);

    let temperature_kelvin = calculate_temperature(current_res, B_VALUE);
    let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
    esp_println::println!("Temperature:{:.2} °C", temperature_celsius);

    Timer::after(Duration::from_secs(1)).await;
}
}

Не забудьте добавить константу ADC_LUT из раздела нелинейность АЦП.

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку room-temperature.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/room-temperature

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// ADC
use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

const fn kelvin_to_celsius(kelvin: f64) -> f64 {
    kelvin - 273.15
}

const fn celsius_to_kelvin(celsius: f64) -> f64 {
    celsius + 273.15
}

const ADC_MAX: f64 = 4095.0; // 4095 для 12-битного АЦП
const B_VALUE: f64 = 3950.0;
const REF_TEMP: f64 = 25.0; // Опорная температура 25°C
const REF_RES: f64 = 10_000.0; // Сопротивление термистора при опорной температуре (25°C)
const REF_TEMP_K: f64 = celsius_to_kelvin(REF_TEMP);
const R1_RES: f64 = REF_RES; // 10_000.0 Ом

fn adc_to_resistance(adc_value: f64) -> f64 {
    let x: f64 = adc_value / (ADC_MAX - adc_value);
    R1_RES * x
}

// B-уравнение для перевода сопротивления в температуру
fn calculate_temperature(current_res: f64, b_val: f64) -> f64 {
    let ln_value = libm::log(current_res / REF_RES); // Используем libm для `no_std`
    let inv_t = (1.0 / REF_TEMP_K) + ((1.0 / b_val) * ln_value);
    1.0 / inv_t
}

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let adc_pin = peripherals.GPIO13;
    let mut adc2_config = AdcConfig::new();
    let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::_11dB);
    let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);

    loop {
        let adc_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
        esp_println::println!("ADC: {}", adc_value);
        let adc_value: f64 = ADC_LUT[adc_value as usize];
        esp_println::println!("Corrected ADC: {}", adc_value);

        let current_res = adc_to_resistance(adc_value);
        esp_println::println!("R2: {}", current_res);

        let temperature_kelvin = calculate_temperature(current_res, B_VALUE);
        let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
        esp_println::println!("Temperature:{:.2} °C", temperature_celsius);

        Timer::after(Duration::from_secs(1)).await;
    }
}

// Таблица поиска (LUT) для исправления нелинейности АЦП
// https://github.com/e-tinkers/esp32-adc-calibrate
// https://www.e-tinkers.com/2019/10/using-a-thermistor-with-arduino-and-unexpected-esp32-adc-non-linearity/
const ADC_LUT: &[f64; 4096] = &[
    0.0, 21.8000, 32.0000, 33.8000, 35.0000, 36.4000, 37.8000, 39.0000, 40.8000, 42.0000, 43.6000,
    44.8000, 46.2000, 47.8000, 49.0000, 50.0000, 51.2000, 52.6000, 53.8000, 54.8000, 56.0000,
    57.0000, 58.4000, 59.8000, 60.8000, 62.0000, 63.0000, 64.2000, 65.4000, 66.6000, 67.8000,
    68.8000, 69.8000, 70.8000, 72.0000, 73.0000, 74.0000, 75.2000, 76.4000, 77.6000, 78.8000,
    79.8000, 80.8000, 81.8000, 82.8000, 84.0000, 85.0000, 86.0000, 87.0000, 88.0000, 89.0000,
    90.0000, 91.0000, 92.2000, 93.2000, 94.4000, 95.4000, 96.6000, 97.8000, 98.8000, 99.8000,
    101.0000, 102.0000, 103.2000, 104.4000, 105.6000, 106.8000, 107.8000, 109.0000, 110.0000,
    111.0000, 112.4000, 113.8000, 115.4000, 117.0000, 118.6000, 120.0000, 121.6000, 123.0000,
    124.8000, 126.0000, 127.8000, 128.8000, 129.8000, 130.8000, 131.8000, 132.6000, 133.6000,
    134.4000, 135.2000, 136.2000, 137.0000, 138.0000, 139.0000, 140.0000, 141.0000, 141.8000,
    142.8000, 143.8000, 144.8000, 146.0000, 147.2000, 148.8000, 149.8000, 151.0000, 152.2000,
    153.6000, 154.8000, 156.0000, 157.2000, 158.6000, 159.8000, 160.8000, 161.8000, 162.8000,
    163.8000, 164.8000, 165.8000, 166.8000, 167.8000, 168.8000, 169.8000, 170.8000, 171.8000,
    172.8000, 173.8000, 174.8000, 175.8000, 176.8000, 177.8000, 179.0000, 180.0000, 181.4000,
    182.6000, 183.8000, 184.8000, 186.0000, 187.0000, 188.2000, 189.6000, 190.8000, 191.8000,
    193.0000, 194.0000, 195.4000, 196.8000, 197.8000, 199.0000, 200.0000, 201.2000, 202.6000,
    203.8000, 205.0000, 206.0000, 207.2000, 208.4000, 209.6000, 210.6000, 211.8000, 212.8000,
    213.8000, 214.8000, 215.8000, 216.8000, 217.8000, 218.8000, 220.0000, 221.0000, 222.0000,
    223.0000, 224.0000, 225.0000, 226.0000, 227.0000, 228.0000, 229.0000, 230.2000, 231.2000,
    232.2000, 233.4000, 234.4000, 235.4000, 236.4000, 237.6000, 238.6000, 239.6000, 240.8000,
    242.0000, 243.4000, 244.8000, 246.0000, 247.4000, 248.8000, 250.0000, 251.6000, 252.8000,
    254.0000, 255.6000, 256.8000, 257.8000, 258.8000, 259.6000, 260.6000, 261.4000, 262.4000,
    263.4000, 264.2000, 265.2000, 266.0000, 267.0000, 268.0000, 269.0000, 270.0000, 271.0000,
    272.0000, 273.0000, 274.6000, 275.8000, 277.0000, 278.4000, 279.8000, 281.0000, 282.2000,
    283.8000, 284.8000, 286.0000, 287.6000, 288.8000, 289.8000, 290.8000, 291.8000, 292.8000,
    293.8000, 294.8000, 295.8000, 296.8000, 297.8000, 298.8000, 299.8000, 300.8000, 301.8000,
    302.8000, 303.8000, 304.8000, 305.8000, 307.0000, 308.0000, 309.0000, 310.0000, 311.0000,
    312.2000, 313.4000, 314.6000, 315.8000, 316.8000, 317.8000, 318.8000, 320.0000, 321.0000,
    322.4000, 323.8000, 325.0000, 326.2000, 327.6000, 328.8000, 330.0000, 331.4000, 332.8000,
    334.0000, 335.0000, 336.4000, 337.6000, 338.8000, 339.8000, 340.8000, 342.0000, 343.0000,
    344.0000, 345.2000, 346.4000, 347.6000, 348.8000, 349.8000, 350.8000, 352.0000, 353.0000,
    354.0000, 355.0000, 356.0000, 357.0000, 358.2000, 359.4000, 360.4000, 361.6000, 362.6000,
    363.8000, 364.8000, 365.8000, 366.8000, 367.8000, 368.8000, 370.0000, 371.0000, 372.0000,
    373.0000, 374.0000, 375.0000, 376.2000, 377.4000, 378.4000, 379.6000, 380.6000, 381.8000,
    382.8000, 383.8000, 384.8000, 386.0000, 387.2000, 388.6000, 389.8000, 390.8000, 392.0000,
    393.2000, 394.6000, 395.8000, 396.8000, 398.0000, 399.2000, 400.6000, 401.8000, 403.0000,
    404.8000, 406.0000, 407.2000, 408.8000, 410.0000, 411.2000, 412.8000, 414.0000, 415.4000,
    416.8000, 417.8000, 418.6000, 419.6000, 420.6000, 421.4000, 422.4000, 423.4000, 424.2000,
    425.2000, 426.2000, 427.0000, 428.0000, 429.0000, 430.0000, 431.0000, 432.0000, 433.6000,
    435.0000, 436.6000, 438.0000, 439.4000, 441.0000, 442.4000, 443.8000, 445.4000, 446.8000,
    448.2000, 449.0000, 449.8000, 450.8000, 451.4000, 452.0000, 453.0000, 453.8000, 454.8000,
    455.2000, 456.0000, 457.0000, 457.8000, 458.6000, 459.2000, 460.0000, 460.8000, 461.8000,
    462.4000, 463.0000, 464.0000, 465.0000, 466.0000, 467.2000, 468.6000, 469.8000, 470.8000,
    471.8000, 473.0000, 474.0000, 475.2000, 476.4000, 477.8000, 478.8000, 479.8000, 481.0000,
    482.8000, 484.0000, 485.4000, 486.8000, 488.0000, 489.8000, 491.0000, 492.4000, 493.8000,
    495.2000, 496.6000, 497.4000, 498.2000, 499.0000, 500.0000, 501.0000, 502.0000, 502.8000,
    503.8000, 504.8000, 505.8000, 506.6000, 507.4000, 508.2000, 509.0000, 510.0000, 511.0000,
    512.0000, 513.0000, 514.2000, 515.6000, 516.8000, 518.0000, 519.0000, 520.4000, 521.8000,
    522.8000, 524.0000, 525.2000, 526.6000, 527.8000, 529.0000, 530.0000, 531.2000, 532.6000,
    533.8000, 535.0000, 536.0000, 537.2000, 538.6000, 539.8000, 541.0000, 542.0000, 543.2000,
    544.6000, 545.6000, 546.8000, 547.8000, 548.8000, 549.8000, 550.8000, 551.8000, 552.8000,
    553.8000, 555.0000, 556.0000, 557.0000, 558.0000, 559.0000, 560.0000, 561.4000, 562.8000,
    563.8000, 565.0000, 566.4000, 567.8000, 568.8000, 570.0000, 571.4000, 572.8000, 573.8000,
    575.0000, 576.2000, 577.2000, 578.4000, 579.4000, 580.4000, 581.4000, 582.4000, 583.4000,
    584.6000, 585.6000, 586.6000, 587.6000, 588.6000, 589.6000, 590.6000, 591.8000, 592.8000,
    593.8000, 595.0000, 596.0000, 597.0000, 598.2000, 599.6000, 600.8000, 601.8000, 603.0000,
    604.0000, 605.0000, 606.2000, 607.6000, 608.6000, 609.0000, 610.0000, 610.8000, 611.6000,
    612.2000, 613.0000, 613.8000, 614.8000, 615.2000, 616.0000, 616.8000, 617.8000, 618.4000,
    619.0000, 620.0000, 620.8000, 621.4000, 622.0000, 623.0000, 623.8000, 624.8000, 625.8000,
    626.8000, 627.8000, 629.0000, 630.0000, 631.0000, 632.0000, 633.0000, 634.2000, 635.4000,
    636.6000, 637.6000, 638.8000, 639.8000, 641.0000, 642.0000, 643.6000, 644.8000, 646.0000,
    647.4000, 648.8000, 650.0000, 651.4000, 652.8000, 654.0000, 655.2000, 656.6000, 657.8000,
    658.8000, 659.8000, 660.8000, 661.8000, 663.0000, 664.0000, 665.0000, 666.0000, 667.2000,
    668.2000, 669.4000, 670.6000, 671.6000, 672.8000, 674.0000, 675.6000, 676.8000, 678.0000,
    679.8000, 681.0000, 682.2000, 683.8000, 685.0000, 686.4000, 687.8000, 689.0000, 690.0000,
    691.0000, 692.0000, 693.0000, 694.0000, 695.0000, 696.2000, 697.2000, 698.2000, 699.4000,
    700.4000, 701.6000, 702.6000, 703.8000, 704.6000, 705.4000, 706.2000, 707.0000, 708.0000,
    708.8000, 709.8000, 710.8000, 711.6000, 712.4000, 713.0000, 714.0000, 715.0000, 715.8000,
    716.8000, 717.8000, 718.6000, 719.2000, 720.0000, 721.8000, 723.0000, 724.4000, 725.8000,
    727.0000, 728.8000, 730.0000, 731.4000, 732.8000, 734.0000, 735.6000, 736.8000, 737.8000,
    738.8000, 739.8000, 740.8000, 742.0000, 743.0000, 744.0000, 745.0000, 746.0000, 747.0000,
    748.0000, 749.0000, 750.0000, 751.0000, 752.2000, 753.6000, 754.8000, 756.0000, 757.4000,
    758.8000, 760.0000, 761.2000, 762.6000, 763.8000, 765.0000, 766.4000, 767.8000, 768.8000,
    770.0000, 771.0000, 772.0000, 773.0000, 774.2000, 775.4000, 776.6000, 777.8000, 778.8000,
    779.8000, 780.8000, 782.0000, 783.0000, 784.0000, 785.2000, 786.6000, 787.8000, 789.0000,
    790.0000, 791.4000, 792.8000, 793.8000, 795.0000, 796.0000, 797.4000, 798.8000, 799.8000,
    801.0000, 802.0000, 803.0000, 804.4000, 805.6000, 806.8000, 807.8000, 808.8000, 810.0000,
    811.0000, 812.0000, 813.2000, 814.4000, 815.8000, 816.8000, 817.8000, 818.8000, 819.8000,
    820.8000, 821.8000, 822.8000, 823.8000, 824.8000, 825.8000, 826.8000, 827.8000, 828.8000,
    829.8000, 830.8000, 831.8000, 833.0000, 834.0000, 835.0000, 836.2000, 837.4000, 838.6000,
    839.8000, 840.8000, 841.8000, 843.0000, 844.0000, 845.0000, 846.2000, 847.4000, 848.8000,
    849.8000, 851.0000, 852.8000, 853.8000, 855.0000, 856.8000, 857.8000, 859.0000, 860.8000,
    861.8000, 863.0000, 864.6000, 865.4000, 866.4000, 867.2000, 868.2000, 869.0000, 870.0000,
    871.0000, 872.0000, 873.0000, 874.0000, 875.0000, 876.0000, 876.8000, 877.8000, 878.8000,
    879.8000, 880.8000, 881.8000, 883.0000, 884.0000, 885.0000, 886.0000, 887.0000, 888.2000,
    889.2000, 890.4000, 891.6000, 892.6000, 893.8000, 894.8000, 895.8000, 897.0000, 898.2000,
    899.8000, 901.0000, 902.0000, 903.6000, 904.8000, 906.0000, 907.6000, 908.8000, 910.0000,
    911.4000, 912.8000, 913.4000, 914.2000, 915.0000, 916.0000, 916.8000, 917.8000, 918.8000,
    919.6000, 920.2000, 921.0000, 922.0000, 923.0000, 923.8000, 924.8000, 925.6000, 926.4000,
    927.2000, 928.0000, 929.4000, 930.8000, 932.0000, 933.2000, 934.8000, 935.8000, 937.0000,
    938.4000, 939.8000, 941.0000, 942.2000, 943.8000, 944.8000, 946.0000, 947.4000, 948.8000,
    950.0000, 951.0000, 952.6000, 953.8000, 955.0000, 956.2000, 957.8000, 958.8000, 960.0000,
    961.0000, 962.2000, 963.4000, 964.6000, 965.8000, 966.8000, 967.8000, 969.0000, 970.0000,
    971.0000, 972.0000, 973.2000, 974.4000, 975.6000, 976.6000, 977.4000, 978.2000, 979.2000,
    980.0000, 981.0000, 982.0000, 983.0000, 983.8000, 984.8000, 985.8000, 986.8000, 987.6000,
    988.4000, 989.2000, 990.0000, 991.0000, 992.0000, 993.0000, 994.0000, 995.4000, 996.6000,
    997.8000, 998.8000, 1000.0000, 1001.0000, 1002.0000, 1003.2000, 1004.4000, 1005.8000,
    1006.8000, 1007.8000, 1008.8000, 1009.0000, 1009.8000, 1010.2000, 1010.8000, 1011.4000,
    1012.0000, 1012.6000, 1013.0000, 1013.8000, 1014.0000, 1014.8000, 1015.4000, 1016.0000,
    1016.6000, 1017.0000, 1017.8000, 1018.0000, 1018.8000, 1019.2000, 1019.8000, 1020.6000,
    1021.0000, 1021.8000, 1022.0000, 1022.8000, 1023.2000, 1023.8000, 1024.8000, 1026.0000,
    1027.6000, 1028.8000, 1030.2000, 1031.8000, 1033.0000, 1034.4000, 1035.8000, 1037.0000,
    1038.8000, 1040.0000, 1041.0000, 1042.0000, 1043.0000, 1044.0000, 1045.0000, 1046.0000,
    1047.0000, 1048.0000, 1049.0000, 1050.0000, 1051.0000, 1052.0000, 1053.0000, 1054.0000,
    1055.0000, 1056.0000, 1057.0000, 1058.2000, 1059.6000, 1060.8000, 1061.8000, 1063.0000,
    1064.0000, 1065.2000, 1066.6000, 1067.8000, 1068.8000, 1070.0000, 1071.0000, 1072.2000,
    1073.0000, 1074.0000, 1075.0000, 1076.0000, 1077.0000, 1078.0000, 1078.8000, 1079.8000,
    1080.8000, 1081.8000, 1082.8000, 1083.8000, 1084.6000, 1085.6000, 1086.4000, 1087.2000,
    1088.2000, 1089.6000, 1090.8000, 1092.0000, 1093.0000, 1094.4000, 1095.8000, 1096.8000,
    1098.0000, 1099.2000, 1100.8000, 1101.8000, 1103.0000, 1104.0000, 1105.2000, 1106.4000,
    1107.6000, 1108.8000, 1109.8000, 1110.8000, 1112.0000, 1113.0000, 1114.0000, 1115.2000,
    1116.4000, 1117.6000, 1118.8000, 1119.8000, 1120.8000, 1121.8000, 1123.0000, 1124.0000,
    1125.0000, 1126.0000, 1127.0000, 1128.2000, 1129.4000, 1130.6000, 1131.6000, 1132.8000,
    1133.8000, 1134.8000, 1135.8000, 1136.8000, 1137.8000, 1138.8000, 1139.6000, 1140.4000,
    1141.2000, 1142.0000, 1143.0000, 1144.0000, 1144.8000, 1145.8000, 1146.8000, 1147.6000,
    1148.4000, 1149.2000, 1150.0000, 1151.0000, 1152.0000, 1153.0000, 1154.0000, 1155.0000,
    1156.0000, 1157.2000, 1158.4000, 1159.6000, 1160.8000, 1161.8000, 1162.8000, 1163.8000,
    1164.8000, 1166.0000, 1167.0000, 1168.0000, 1169.6000, 1170.8000, 1172.0000, 1173.8000,
    1175.0000, 1176.2000, 1177.8000, 1179.0000, 1180.4000, 1181.8000, 1183.0000, 1184.4000,
    1185.0000, 1186.0000, 1186.8000, 1187.6000, 1188.2000, 1189.0000, 1189.8000, 1190.8000,
    1191.6000, 1192.0000, 1193.0000, 1193.8000, 1194.8000, 1195.4000, 1196.0000, 1197.0000,
    1197.8000, 1198.6000, 1199.2000, 1200.0000, 1201.2000, 1202.6000, 1203.8000, 1205.0000,
    1206.2000, 1207.6000, 1208.8000, 1210.0000, 1211.0000, 1212.6000, 1213.8000, 1215.0000,
    1216.0000, 1217.2000, 1218.2000, 1219.4000, 1220.6000, 1221.6000, 1222.8000, 1223.8000,
    1224.8000, 1225.8000, 1226.8000, 1227.8000, 1229.0000, 1230.0000, 1231.0000, 1232.0000,
    1233.0000, 1234.2000, 1235.4000, 1236.6000, 1237.8000, 1238.8000, 1239.8000, 1241.0000,
    1242.0000, 1243.0000, 1244.0000, 1245.2000, 1246.4000, 1247.6000, 1248.8000, 1249.8000,
    1250.6000, 1251.6000, 1252.6000, 1253.6000, 1254.4000, 1255.4000, 1256.4000, 1257.4000,
    1258.2000, 1259.2000, 1260.2000, 1261.2000, 1262.0000, 1263.0000, 1264.0000, 1265.6000,
    1266.8000, 1268.0000, 1269.4000, 1270.8000, 1272.0000, 1273.4000, 1274.8000, 1276.0000,
    1277.2000, 1278.8000, 1280.0000, 1281.0000, 1282.0000, 1283.0000, 1284.0000, 1285.0000,
    1285.8000, 1286.8000, 1287.8000, 1288.8000, 1289.8000, 1290.8000, 1291.8000, 1292.8000,
    1293.8000, 1294.8000, 1295.8000, 1296.8000, 1298.0000, 1299.2000, 1300.6000, 1301.8000,
    1302.8000, 1304.0000, 1305.2000, 1306.4000, 1307.8000, 1308.8000, 1310.0000, 1311.0000,
    1312.4000, 1313.6000, 1314.8000, 1315.8000, 1316.8000, 1317.8000, 1319.0000, 1320.0000,
    1321.0000, 1322.0000, 1323.2000, 1324.2000, 1325.4000, 1326.6000, 1327.8000, 1328.8000,
    1329.8000, 1330.8000, 1332.0000, 1333.0000, 1334.0000, 1335.0000, 1336.2000, 1337.4000,
    1338.6000, 1339.6000, 1340.8000, 1341.8000, 1342.8000, 1343.8000, 1345.0000, 1346.2000,
    1347.6000, 1348.8000, 1349.8000, 1351.0000, 1352.0000, 1353.4000, 1354.8000, 1355.8000,
    1357.0000, 1358.0000, 1359.4000, 1360.8000, 1361.8000, 1362.8000, 1363.8000, 1364.8000,
    1365.8000, 1366.8000, 1367.8000, 1368.8000, 1369.8000, 1370.8000, 1371.8000, 1372.8000,
    1373.8000, 1374.8000, 1375.8000, 1376.8000, 1377.8000, 1379.0000, 1380.2000, 1381.6000,
    1382.8000, 1383.8000, 1385.0000, 1386.2000, 1387.6000, 1388.8000, 1389.8000, 1391.0000,
    1392.0000, 1393.2000, 1394.4000, 1395.6000, 1396.6000, 1397.8000, 1398.8000, 1399.8000,
    1400.8000, 1401.8000, 1403.0000, 1404.0000, 1405.0000, 1406.0000, 1407.0000, 1408.2000,
    1409.2000, 1410.4000, 1411.4000, 1412.6000, 1413.6000, 1414.8000, 1415.8000, 1416.8000,
    1417.8000, 1418.8000, 1419.8000, 1420.8000, 1422.0000, 1423.0000, 1424.0000, 1425.0000,
    1426.0000, 1427.0000, 1428.0000, 1428.8000, 1429.8000, 1430.8000, 1431.8000, 1432.8000,
    1433.8000, 1434.8000, 1435.8000, 1436.8000, 1437.8000, 1438.8000, 1439.8000, 1440.8000,
    1441.8000, 1443.0000, 1444.2000, 1445.6000, 1446.8000, 1448.0000, 1449.0000, 1450.6000,
    1451.8000, 1453.0000, 1454.0000, 1455.4000, 1456.8000, 1457.8000, 1459.0000, 1460.0000,
    1461.4000, 1462.6000, 1463.8000, 1465.0000, 1466.0000, 1467.2000, 1468.6000, 1469.8000,
    1470.8000, 1472.0000, 1473.0000, 1474.0000, 1475.2000, 1476.4000, 1477.6000, 1478.8000,
    1479.8000, 1480.8000, 1481.8000, 1483.0000, 1484.0000, 1485.0000, 1486.2000, 1487.4000,
    1488.6000, 1489.6000, 1490.8000, 1491.8000, 1492.8000, 1493.8000, 1494.8000, 1496.0000,
    1497.0000, 1498.0000, 1499.0000, 1500.0000, 1501.2000, 1502.4000, 1503.6000, 1504.6000,
    1505.8000, 1506.8000, 1508.0000, 1509.0000, 1510.0000, 1511.2000, 1512.4000, 1513.8000,
    1514.8000, 1515.8000, 1517.0000, 1518.0000, 1519.0000, 1520.2000, 1521.0000, 1522.0000,
    1523.0000, 1523.8000, 1524.8000, 1525.6000, 1526.4000, 1527.2000, 1528.0000, 1529.0000,
    1529.8000, 1530.8000, 1531.8000, 1532.6000, 1533.4000, 1534.2000, 1535.0000, 1536.0000,
    1537.0000, 1538.2000, 1539.4000, 1540.8000, 1541.8000, 1543.0000, 1544.0000, 1545.2000,
    1546.6000, 1547.8000, 1548.8000, 1550.0000, 1551.0000, 1552.2000, 1553.6000, 1554.8000,
    1556.0000, 1557.0000, 1558.4000, 1559.8000, 1560.8000, 1562.0000, 1563.0000, 1564.4000,
    1565.8000, 1566.8000, 1568.0000, 1569.0000, 1570.0000, 1571.0000, 1571.8000, 1572.8000,
    1573.8000, 1574.8000, 1575.8000, 1576.8000, 1577.8000, 1578.8000, 1579.6000, 1580.6000,
    1581.6000, 1582.4000, 1583.4000, 1584.4000, 1585.8000, 1587.0000, 1588.8000, 1590.0000,
    1591.2000, 1592.8000, 1594.0000, 1595.6000, 1597.0000, 1598.2000, 1599.8000, 1600.8000,
    1601.8000, 1602.6000, 1603.2000, 1604.0000, 1605.0000, 1605.8000, 1606.8000, 1607.8000,
    1608.6000, 1609.2000, 1610.0000, 1611.0000, 1612.0000, 1612.8000, 1613.8000, 1614.6000,
    1615.2000, 1616.0000, 1617.4000, 1618.6000, 1619.8000, 1620.8000, 1622.0000, 1623.0000,
    1624.4000, 1625.8000, 1626.8000, 1627.8000, 1629.0000, 1630.0000, 1631.4000, 1632.6000,
    1633.8000, 1634.8000, 1635.8000, 1637.0000, 1638.0000, 1639.0000, 1640.4000, 1641.6000,
    1642.8000, 1643.8000, 1644.8000, 1646.0000, 1647.0000, 1648.0000, 1649.2000, 1650.2000,
    1651.4000, 1652.6000, 1653.8000, 1654.8000, 1655.8000, 1656.8000, 1658.0000, 1659.0000,
    1660.0000, 1661.0000, 1662.0000, 1663.2000, 1664.6000, 1665.8000, 1667.0000, 1668.2000,
    1669.8000, 1671.0000, 1672.0000, 1673.6000, 1674.8000, 1676.0000, 1677.4000, 1678.8000,
    1680.0000, 1681.0000, 1682.0000, 1682.8000, 1683.8000, 1684.8000, 1685.8000, 1686.8000,
    1687.8000, 1688.8000, 1689.6000, 1690.6000, 1691.4000, 1692.4000, 1693.2000, 1694.0000,
    1695.0000, 1696.0000, 1697.8000, 1699.2000, 1700.8000, 1702.6000, 1704.0000, 1705.8000,
    1707.2000, 1708.8000, 1710.6000, 1712.0000, 1712.8000, 1713.6000, 1714.0000, 1715.0000,
    1715.8000, 1716.2000, 1717.0000, 1717.8000, 1718.4000, 1719.0000, 1719.8000, 1720.6000,
    1721.0000, 1722.0000, 1722.8000, 1723.2000, 1724.0000, 1724.8000, 1725.4000, 1726.0000,
    1726.8000, 1727.6000, 1728.2000, 1729.2000, 1730.2000, 1731.2000, 1732.2000, 1733.2000,
    1734.2000, 1735.2000, 1736.2000, 1737.4000, 1738.4000, 1739.4000, 1740.4000, 1741.4000,
    1742.4000, 1743.4000, 1744.4000, 1745.4000, 1746.4000, 1747.4000, 1748.4000, 1749.4000,
    1750.4000, 1751.4000, 1752.4000, 1753.4000, 1754.4000, 1755.4000, 1756.4000, 1757.4000,
    1758.4000, 1759.4000, 1760.6000, 1761.8000, 1763.0000, 1764.0000, 1765.4000, 1766.8000,
    1767.8000, 1769.0000, 1770.4000, 1771.8000, 1772.8000, 1774.0000, 1775.2000, 1776.6000,
    1777.8000, 1778.8000, 1780.0000, 1781.0000, 1782.0000, 1783.2000, 1784.4000, 1785.8000,
    1786.8000, 1787.8000, 1789.0000, 1790.0000, 1791.0000, 1792.2000, 1793.6000, 1794.8000,
    1795.8000, 1797.0000, 1798.0000, 1799.2000, 1800.4000, 1801.8000, 1802.8000, 1803.8000,
    1805.0000, 1806.0000, 1807.2000, 1808.6000, 1809.8000, 1810.8000, 1811.8000, 1812.8000,
    1813.8000, 1815.0000, 1816.0000, 1817.0000, 1818.0000, 1819.0000, 1820.2000, 1821.2000,
    1822.4000, 1823.6000, 1824.8000, 1825.8000, 1826.8000, 1828.0000, 1829.0000, 1830.4000,
    1831.8000, 1832.8000, 1833.8000, 1835.0000, 1836.0000, 1837.4000, 1838.6000, 1839.8000,
    1840.8000, 1842.0000, 1843.2000, 1844.6000, 1845.8000, 1847.0000, 1848.0000, 1849.4000,
    1850.8000, 1851.8000, 1853.0000, 1854.0000, 1855.4000, 1856.6000, 1857.6000, 1858.6000,
    1859.6000, 1860.4000, 1861.4000, 1862.4000, 1863.4000, 1864.2000, 1865.2000, 1866.2000,
    1867.2000, 1868.0000, 1869.0000, 1870.0000, 1871.0000, 1872.0000, 1873.2000, 1874.6000,
    1875.8000, 1876.8000, 1878.0000, 1879.0000, 1880.2000, 1881.6000, 1882.8000, 1883.8000,
    1885.0000, 1886.0000, 1887.2000, 1888.6000, 1889.6000, 1890.6000, 1891.8000, 1892.8000,
    1893.8000, 1894.8000, 1895.8000, 1896.8000, 1897.8000, 1898.8000, 1900.0000, 1901.0000,
    1902.0000, 1903.0000, 1904.0000, 1905.0000, 1906.0000, 1907.0000, 1908.0000, 1909.0000,
    1910.0000, 1911.2000, 1912.2000, 1913.2000, 1914.2000, 1915.4000, 1916.4000, 1917.4000,
    1918.4000, 1919.6000, 1920.8000, 1921.8000, 1923.0000, 1924.0000, 1925.6000, 1926.8000,
    1928.0000, 1929.0000, 1930.4000, 1931.8000, 1932.8000, 1934.0000, 1935.2000, 1936.4000,
    1937.6000, 1938.6000, 1939.6000, 1940.6000, 1941.6000, 1942.8000, 1943.8000, 1944.8000,
    1945.8000, 1946.8000, 1947.8000, 1948.8000, 1949.8000, 1950.8000, 1951.8000, 1952.8000,
    1953.8000, 1955.0000, 1956.0000, 1957.0000, 1958.0000, 1959.2000, 1960.4000, 1961.4000,
    1962.6000, 1963.8000, 1964.8000, 1965.8000, 1966.8000, 1968.0000, 1969.0000, 1970.0000,
    1971.0000, 1972.0000, 1973.0000, 1974.2000, 1975.2000, 1976.4000, 1977.4000, 1978.6000,
    1979.8000, 1980.8000, 1981.8000, 1982.8000, 1983.8000, 1985.0000, 1986.2000, 1987.8000,
    1989.0000, 1990.0000, 1991.8000, 1992.8000, 1994.0000, 1995.6000, 1996.8000, 1998.0000,
    1999.4000, 2000.8000, 2001.8000, 2002.8000, 2004.0000, 2005.0000, 2006.0000, 2007.2000,
    2008.4000, 2009.6000, 2010.8000, 2011.8000, 2012.8000, 2013.8000, 2015.0000, 2016.0000,
    2017.0000, 2018.0000, 2019.2000, 2020.2000, 2021.4000, 2022.4000, 2023.6000, 2024.8000,
    2025.8000, 2026.8000, 2027.8000, 2028.8000, 2029.8000, 2031.0000, 2032.0000, 2032.8000,
    2033.0000, 2033.6000, 2034.0000, 2034.6000, 2035.0000, 2035.4000, 2036.0000, 2036.4000,
    2036.8000, 2037.2000, 2037.8000, 2038.2000, 2038.8000, 2039.2000, 2039.8000, 2040.0000,
    2040.8000, 2041.0000, 2041.8000, 2042.0000, 2042.8000, 2043.0000, 2043.6000, 2044.0000,
    2044.6000, 2045.0000, 2045.6000, 2046.0000, 2046.4000, 2046.8000, 2047.4000, 2047.8000,
    2048.6001, 2049.8000, 2050.8000, 2052.0000, 2053.2000, 2054.6001, 2055.8000, 2056.8000,
    2058.0000, 2059.0000, 2060.3999, 2061.8000, 2062.8000, 2064.0000, 2065.0000, 2066.0000,
    2066.8000, 2067.8000, 2068.8000, 2069.8000, 2070.8000, 2071.8000, 2072.8000, 2073.8000,
    2074.8000, 2075.6001, 2076.6001, 2077.6001, 2078.3999, 2079.3999, 2080.2000, 2081.2000,
    2082.2000, 2083.0000, 2084.0000, 2085.0000, 2086.0000, 2087.0000, 2088.0000, 2089.0000,
    2090.0000, 2091.0000, 2092.0000, 2092.8000, 2093.8000, 2094.8000, 2095.8000, 2097.0000,
    2098.0000, 2099.6001, 2100.8000, 2102.0000, 2103.3999, 2104.8000, 2106.0000, 2107.3999,
    2108.8000, 2110.0000, 2111.2000, 2112.8000, 2113.8000, 2115.0000, 2116.6001, 2117.8000,
    2119.0000, 2120.2000, 2121.8000, 2123.0000, 2124.0000, 2125.6001, 2126.8000, 2128.0000,
    2129.0000, 2130.0000, 2130.8000, 2131.8000, 2132.8000, 2133.8000, 2134.8000, 2135.6001,
    2136.3999, 2137.2000, 2138.0000, 2139.0000, 2140.0000, 2141.0000, 2142.0000, 2142.8000,
    2143.8000, 2144.8000, 2146.0000, 2147.8000, 2149.0000, 2150.0000, 2151.8000, 2153.0000,
    2154.2000, 2155.8000, 2157.0000, 2158.2000, 2159.8000, 2160.8000, 2161.8000, 2163.0000,
    2164.0000, 2165.0000, 2166.0000, 2167.0000, 2168.0000, 2169.2000, 2170.2000, 2171.3999,
    2172.6001, 2173.6001, 2174.8000, 2175.8000, 2176.8000, 2177.8000, 2178.8000, 2180.0000,
    2181.0000, 2182.0000, 2183.0000, 2184.0000, 2185.2000, 2186.3999, 2187.3999, 2188.6001,
    2189.8000, 2190.8000, 2191.8000, 2193.0000, 2194.0000, 2195.6001, 2196.8000, 2198.0000,
    2199.3999, 2200.8000, 2202.0000, 2203.2000, 2204.8000, 2205.8000, 2207.0000, 2208.3999,
    2209.6001, 2210.8000, 2211.8000, 2212.8000, 2213.8000, 2215.0000, 2216.0000, 2217.0000,
    2218.2000, 2219.3999, 2220.6001, 2221.8000, 2222.8000, 2223.8000, 2224.8000, 2225.6001,
    2226.3999, 2227.0000, 2228.0000, 2228.8000, 2229.8000, 2230.6001, 2231.3999, 2232.0000,
    2233.0000, 2233.8000, 2234.8000, 2235.6001, 2236.2000, 2237.0000, 2238.0000, 2238.8000,
    2239.8000, 2240.8000, 2241.8000, 2242.8000, 2243.8000, 2244.8000, 2245.8000, 2246.8000,
    2247.8000, 2248.8000, 2249.8000, 2250.8000, 2251.8000, 2252.8000, 2253.8000, 2254.8000,
    2255.8000, 2256.8000, 2257.8000, 2258.8000, 2260.0000, 2261.0000, 2262.2000, 2263.3999,
    2264.8000, 2265.8000, 2266.8000, 2268.0000, 2269.0000, 2270.2000, 2271.3999, 2272.6001,
    2273.6001, 2274.3999, 2275.3999, 2276.2000, 2277.2000, 2278.0000, 2279.0000, 2280.0000,
    2281.0000, 2282.0000, 2283.0000, 2284.0000, 2285.0000, 2286.0000, 2286.8000, 2287.8000,
    2289.0000, 2290.0000, 2291.0000, 2292.2000, 2293.6001, 2294.8000, 2295.8000, 2296.8000,
    2298.0000, 2299.0000, 2300.2000, 2301.6001, 2302.8000, 2303.8000, 2304.8000, 2306.0000,
    2307.0000, 2308.2000, 2309.6001, 2310.8000, 2311.8000, 2312.8000, 2314.0000, 2315.0000,
    2316.2000, 2317.6001, 2318.8000, 2319.8000, 2321.0000, 2322.0000, 2323.3999, 2324.8000,
    2325.8000, 2327.0000, 2328.2000, 2329.6001, 2330.8000, 2332.0000, 2333.0000, 2334.3999,
    2335.8000, 2336.8000, 2338.0000, 2339.0000, 2340.2000, 2341.6001, 2342.8000, 2343.8000,
    2345.0000, 2346.0000, 2347.0000, 2348.3999, 2349.6001, 2350.8000, 2351.8000, 2353.0000,
    2354.0000, 2355.0000, 2356.2000, 2357.3999, 2358.6001, 2359.8000, 2360.8000, 2361.8000,
    2363.0000, 2364.0000, 2365.0000, 2366.2000, 2367.3999, 2368.3999, 2369.3999, 2370.2000,
    2371.0000, 2372.0000, 2373.0000, 2374.0000, 2375.0000, 2375.8000, 2376.8000, 2377.8000,
    2378.8000, 2379.8000, 2380.6001, 2381.3999, 2382.3999, 2383.2000, 2384.2000, 2385.8000,
    2387.0000, 2388.2000, 2389.8000, 2391.0000, 2392.2000, 2393.8000, 2395.0000, 2396.2000,
    2397.8000, 2399.0000, 2400.2000, 2401.2000, 2402.2000, 2403.2000, 2404.2000, 2405.2000,
    2406.2000, 2407.2000, 2408.2000, 2409.2000, 2410.2000, 2411.2000, 2412.2000, 2413.2000,
    2414.2000, 2415.2000, 2416.2000, 2417.3999, 2418.8000, 2419.8000, 2420.8000, 2422.0000,
    2423.0000, 2424.2000, 2425.6001, 2426.8000, 2427.8000, 2429.0000, 2430.0000, 2431.2000,
    2432.3999, 2433.8000, 2435.0000, 2436.2000, 2437.8000, 2438.8000, 2440.0000, 2441.3999,
    2442.8000, 2444.0000, 2445.2000, 2446.8000, 2447.8000, 2449.0000, 2450.0000, 2451.0000,
    2452.0000, 2453.0000, 2454.2000, 2455.3999, 2456.3999, 2457.6001, 2458.8000, 2459.8000,
    2460.8000, 2461.8000, 2462.8000, 2463.8000, 2465.0000, 2466.0000, 2467.0000, 2468.2000,
    2469.3999, 2470.6001, 2471.8000, 2472.8000, 2473.8000, 2475.0000, 2476.0000, 2477.0000,
    2478.0000, 2479.2000, 2480.3999, 2481.3999, 2482.2000, 2483.2000, 2484.2000, 2485.2000,
    2486.0000, 2487.0000, 2488.0000, 2489.0000, 2490.0000, 2491.0000, 2492.0000, 2493.0000,
    2494.0000, 2495.0000, 2496.0000, 2497.0000, 2498.3999, 2499.8000, 2501.0000, 2502.0000,
    2503.6001, 2504.8000, 2506.0000, 2507.3999, 2508.8000, 2510.0000, 2511.0000, 2512.3999,
    2513.6001, 2514.8000, 2515.8000, 2516.8000, 2517.8000, 2519.0000, 2520.0000, 2521.0000,
    2522.0000, 2523.2000, 2524.3999, 2525.6001, 2526.8000, 2527.8000, 2528.8000, 2529.8000,
    2531.0000, 2532.0000, 2533.0000, 2534.2000, 2535.3999, 2536.6001, 2537.8000, 2538.8000,
    2539.8000, 2541.0000, 2542.0000, 2543.0000, 2544.2000, 2545.0000, 2546.0000, 2547.0000,
    2548.0000, 2549.0000, 2549.8000, 2550.8000, 2551.8000, 2552.8000, 2553.8000, 2554.6001,
    2555.3999, 2556.3999, 2557.2000, 2558.0000, 2559.0000, 2560.0000, 2561.3999, 2562.8000,
    2564.0000, 2565.3999, 2566.8000, 2568.0000, 2569.6001, 2570.8000, 2572.0000, 2573.6001,
    2574.8000, 2576.0000, 2577.0000, 2578.0000, 2579.0000, 2580.0000, 2581.0000, 2582.0000,
    2583.0000, 2584.0000, 2585.0000, 2586.0000, 2587.0000, 2587.8000, 2588.8000, 2589.8000,
    2590.8000, 2591.8000, 2592.8000, 2594.0000, 2595.0000, 2596.2000, 2597.3999, 2598.8000,
    2599.8000, 2600.8000, 2602.0000, 2603.0000, 2604.2000, 2605.3999, 2606.6001, 2607.8000,
    2609.0000, 2610.0000, 2611.8000, 2612.8000, 2614.0000, 2615.6001, 2616.8000, 2618.0000,
    2619.6001, 2620.8000, 2622.0000, 2623.6001, 2624.8000, 2625.8000, 2626.8000, 2627.8000,
    2628.8000, 2629.8000, 2630.8000, 2631.8000, 2632.8000, 2633.8000, 2634.6001, 2635.6001,
    2636.6001, 2637.6001, 2638.6001, 2639.6001, 2640.8000, 2641.8000, 2643.0000, 2644.0000,
    2645.6001, 2646.8000, 2648.0000, 2649.0000, 2650.2000, 2651.8000, 2652.8000, 2654.0000,
    2655.0000, 2656.3999, 2657.3999, 2658.3999, 2659.3999, 2660.3999, 2661.3999, 2662.2000,
    2663.2000, 2664.2000, 2665.2000, 2666.2000, 2667.2000, 2668.2000, 2669.2000, 2670.0000,
    2671.0000, 2672.0000, 2673.3999, 2674.8000, 2675.8000, 2677.0000, 2678.0000, 2679.2000,
    2680.6001, 2681.8000, 2682.8000, 2684.0000, 2685.0000, 2686.3999, 2687.8000, 2688.8000,
    2689.8000, 2690.8000, 2692.0000, 2693.0000, 2694.0000, 2695.0000, 2696.2000, 2697.3999,
    2698.6001, 2699.8000, 2700.8000, 2701.8000, 2702.8000, 2704.0000, 2705.0000, 2706.0000,
    2707.0000, 2708.0000, 2709.0000, 2710.0000, 2711.0000, 2712.0000, 2713.0000, 2714.0000,
    2715.0000, 2716.0000, 2717.0000, 2718.2000, 2719.2000, 2720.2000, 2721.3999, 2722.8000,
    2723.8000, 2724.8000, 2726.0000, 2727.0000, 2728.0000, 2729.2000, 2730.3999, 2731.6001,
    2732.8000, 2733.8000, 2734.8000, 2736.0000, 2737.0000, 2738.0000, 2739.0000, 2740.0000,
    2741.0000, 2742.0000, 2743.2000, 2744.2000, 2745.3999, 2746.3999, 2747.3999, 2748.6001,
    2749.6001, 2750.8000, 2751.8000, 2752.8000, 2754.0000, 2755.0000, 2756.6001, 2757.8000,
    2759.0000, 2760.0000, 2761.6001, 2762.8000, 2764.0000, 2765.2000, 2766.6001, 2767.8000,
    2768.8000, 2769.8000, 2770.8000, 2771.8000, 2772.8000, 2774.0000, 2775.0000, 2776.0000,
    2777.0000, 2778.0000, 2779.0000, 2780.0000, 2781.0000, 2782.0000, 2783.0000, 2784.0000,
    2785.0000, 2786.6001, 2787.8000, 2789.0000, 2790.2000, 2791.6001, 2792.8000, 2794.0000,
    2795.2000, 2796.8000, 2797.8000, 2799.0000, 2800.6001, 2802.0000, 2803.8000, 2805.0000,
    2806.8000, 2808.2000, 2810.0000, 2811.6001, 2813.0000, 2814.8000, 2816.0000, 2816.8000,
    2817.6001, 2818.0000, 2818.8000, 2819.2000, 2820.0000, 2820.8000, 2821.0000, 2821.8000,
    2822.6001, 2823.0000, 2823.8000, 2824.2000, 2825.0000, 2825.8000, 2826.0000, 2826.8000,
    2827.6001, 2828.0000, 2828.8000, 2829.2000, 2830.0000, 2830.8000, 2831.0000, 2831.8000,
    2832.8000, 2833.8000, 2834.8000, 2835.8000, 2836.8000, 2837.8000, 2838.8000, 2839.8000,
    2840.8000, 2841.8000, 2842.8000, 2843.8000, 2844.8000, 2845.8000, 2846.8000, 2847.8000,
    2849.0000, 2850.2000, 2851.6001, 2852.8000, 2854.0000, 2855.3999, 2856.8000, 2858.0000,
    2859.0000, 2860.6001, 2861.8000, 2863.0000, 2864.2000, 2865.3999, 2866.6001, 2867.8000,
    2868.8000, 2869.8000, 2871.0000, 2872.0000, 2873.0000, 2874.2000, 2875.3999, 2876.6001,
    2877.8000, 2878.8000, 2879.8000, 2881.0000, 2882.0000, 2883.2000, 2884.6001, 2885.8000,
    2886.8000, 2888.0000, 2889.0000, 2890.2000, 2891.6001, 2892.8000, 2893.8000, 2895.0000,
    2896.0000, 2897.0000, 2898.0000, 2898.8000, 2899.8000, 2900.8000, 2901.8000, 2902.6001,
    2903.3999, 2904.2000, 2905.0000, 2906.0000, 2907.0000, 2908.0000, 2908.8000, 2909.8000,
    2910.8000, 2911.8000, 2912.8000, 2913.8000, 2915.0000, 2916.3999, 2917.8000, 2919.0000,
    2920.0000, 2921.3999, 2922.8000, 2924.0000, 2925.0000, 2926.6001, 2927.8000, 2928.8000,
    2929.8000, 2930.8000, 2931.8000, 2932.8000, 2933.8000, 2934.8000, 2935.8000, 2936.8000,
    2937.8000, 2938.8000, 2939.8000, 2940.8000, 2941.6001, 2942.6001, 2943.6001, 2944.6001,
    2945.8000, 2946.8000, 2948.0000, 2949.0000, 2950.2000, 2951.3999, 2952.8000, 2953.8000,
    2954.8000, 2956.0000, 2957.0000, 2958.2000, 2959.6001, 2960.8000, 2961.8000, 2962.8000,
    2963.8000, 2965.0000, 2966.0000, 2967.0000, 2968.0000, 2969.0000, 2970.2000, 2971.3999,
    2972.6001, 2973.8000, 2974.8000, 2975.8000, 2976.8000, 2977.8000, 2978.8000, 2980.0000,
    2981.0000, 2982.0000, 2983.0000, 2984.0000, 2985.0000, 2986.0000, 2987.2000, 2988.2000,
    2989.3999, 2990.3999, 2991.6001, 2992.8000, 2993.8000, 2994.8000, 2995.8000, 2996.8000,
    2998.0000, 2999.0000, 3000.0000, 3001.0000, 3002.2000, 3003.3999, 3004.6001, 3005.6001,
    3006.8000, 3007.8000, 3008.8000, 3010.0000, 3011.0000, 3012.2000, 3013.3999, 3014.6001,
    3015.8000, 3016.8000, 3018.0000, 3019.0000, 3020.2000, 3021.3999, 3022.8000, 3023.8000,
    3024.8000, 3026.0000, 3027.0000, 3028.0000, 3029.0000, 3030.2000, 3031.3999, 3032.6001,
    3033.8000, 3034.8000, 3035.8000, 3037.0000, 3038.0000, 3039.0000, 3040.0000, 3041.0000,
    3042.0000, 3043.0000, 3044.0000, 3045.0000, 3046.0000, 3047.0000, 3048.0000, 3049.0000,
    3050.0000, 3051.0000, 3051.8000, 3052.8000, 3053.8000, 3054.8000, 3055.8000, 3056.6001,
    3057.0000, 3057.8000, 3058.0000, 3058.8000, 3059.3999, 3060.0000, 3060.6001, 3061.0000,
    3061.8000, 3062.0000, 3062.8000, 3063.3999, 3064.0000, 3064.6001, 3065.0000, 3065.8000,
    3066.0000, 3066.8000, 3067.3999, 3068.0000, 3068.6001, 3069.0000, 3069.8000, 3070.0000,
    3070.8000, 3071.3999, 3072.0000, 3072.8000, 3074.0000, 3075.0000, 3076.0000, 3077.0000,
    3078.2000, 3079.3999, 3080.6001, 3081.8000, 3082.8000, 3083.8000, 3084.8000, 3086.0000,
    3087.0000, 3088.0000, 3089.0000, 3090.0000, 3091.2000, 3092.2000, 3093.3999, 3094.3999,
    3095.6001, 3096.6001, 3097.8000, 3098.8000, 3099.8000, 3100.8000, 3101.8000, 3102.8000,
    3103.8000, 3105.0000, 3106.0000, 3107.0000, 3108.0000, 3109.0000, 3110.0000, 3111.0000,
    3112.2000, 3113.2000, 3114.3999, 3115.6001, 3116.6001, 3117.8000, 3118.8000, 3119.8000,
    3120.8000, 3121.8000, 3123.0000, 3124.0000, 3125.0000, 3126.0000, 3127.2000, 3128.3999,
    3129.6001, 3130.8000, 3131.8000, 3132.8000, 3133.8000, 3135.0000, 3136.0000, 3137.0000,
    3138.0000, 3138.8000, 3139.8000, 3140.8000, 3141.8000, 3142.8000, 3143.6001, 3144.6001,
    3145.3999, 3146.3999, 3147.2000, 3148.0000, 3149.0000, 3150.0000, 3151.0000, 3152.0000,
    3153.0000, 3154.0000, 3155.3999, 3156.6001, 3157.8000, 3158.8000, 3160.0000, 3161.0000,
    3162.0000, 3163.3999, 3164.6001, 3165.8000, 3166.8000, 3168.0000, 3169.0000, 3170.0000,
    3171.0000, 3171.8000, 3172.8000, 3173.8000, 3174.8000, 3175.8000, 3176.8000, 3177.8000,
    3178.8000, 3179.8000, 3180.8000, 3181.8000, 3182.8000, 3183.8000, 3184.8000, 3185.8000,
    3187.0000, 3188.0000, 3189.2000, 3190.6001, 3191.8000, 3192.8000, 3194.0000, 3195.0000,
    3196.3999, 3197.8000, 3198.8000, 3200.0000, 3200.8000, 3201.8000, 3202.8000, 3203.8000,
    3204.6001, 3205.3999, 3206.2000, 3207.0000, 3208.0000, 3209.0000, 3209.8000, 3210.8000,
    3211.8000, 3212.6001, 3213.3999, 3214.2000, 3215.0000, 3216.0000, 3217.0000, 3218.0000,
    3218.8000, 3219.8000, 3220.8000, 3221.8000, 3222.6001, 3223.3999, 3224.2000, 3225.0000,
    3226.0000, 3227.0000, 3228.0000, 3229.0000, 3229.8000, 3230.8000, 3231.8000, 3232.8000,
    3234.0000, 3235.2000, 3236.8000, 3238.0000, 3239.2000, 3240.8000, 3241.8000, 3243.0000,
    3244.6001, 3245.8000, 3247.0000, 3248.3999, 3249.8000, 3251.0000, 3252.3999, 3253.8000,
    3255.0000, 3256.2000, 3257.8000, 3259.0000, 3260.2000, 3261.8000, 3263.0000, 3264.0000,
    3265.0000, 3265.8000, 3266.8000, 3267.6001, 3268.2000, 3269.0000, 3270.0000, 3270.8000,
    3271.6001, 3272.3999, 3273.0000, 3274.0000, 3274.8000, 3275.8000, 3276.3999, 3277.0000,
    3278.0000, 3278.8000, 3279.8000, 3280.6001, 3281.3999, 3282.2000, 3283.0000, 3284.0000,
    3285.0000, 3286.0000, 3286.8000, 3287.8000, 3288.8000, 3289.6001, 3290.3999, 3291.2000,
    3292.0000, 3293.0000, 3294.0000, 3295.0000, 3295.8000, 3296.8000, 3298.0000, 3299.0000,
    3300.0000, 3301.2000, 3302.3999, 3303.6001, 3304.8000, 3305.8000, 3306.8000, 3308.0000,
    3309.0000, 3310.0000, 3311.2000, 3312.3999, 3313.0000, 3314.0000, 3315.0000, 3315.8000,
    3316.8000, 3317.6001, 3318.3999, 3319.0000, 3320.0000, 3321.0000, 3321.8000, 3322.8000,
    3323.6001, 3324.2000, 3325.0000, 3326.0000, 3327.0000, 3327.8000, 3328.8000, 3329.3999,
    3330.0000, 3330.8000, 3331.8000, 3332.3999, 3333.0000, 3334.0000, 3334.8000, 3335.6001,
    3336.2000, 3337.0000, 3337.8000, 3338.8000, 3339.3999, 3340.0000, 3341.0000, 3341.8000,
    3342.6001, 3343.0000, 3344.0000, 3344.8000, 3345.8000, 3346.8000, 3347.6001, 3348.2000,
    3349.0000, 3350.0000, 3351.0000, 3351.8000, 3352.8000, 3353.6001, 3354.3999, 3355.2000,
    3356.0000, 3357.0000, 3357.8000, 3358.8000, 3359.8000, 3360.6001, 3361.8000, 3362.8000,
    3363.8000, 3364.8000, 3365.8000, 3366.8000, 3367.8000, 3369.0000, 3370.0000, 3371.0000,
    3372.0000, 3373.0000, 3374.0000, 3375.2000, 3376.2000, 3377.3999, 3378.3999, 3379.3999,
    3380.3999, 3381.6001, 3382.6001, 3383.6001, 3384.8000, 3385.8000, 3386.8000, 3387.8000,
    3388.8000, 3389.8000, 3390.8000, 3391.8000, 3392.8000, 3394.0000, 3395.0000, 3396.0000,
    3397.0000, 3398.2000, 3399.2000, 3400.3999, 3401.6001, 3402.8000, 3403.8000, 3404.8000,
    3405.8000, 3407.0000, 3408.0000, 3408.8000, 3409.8000, 3410.2000, 3411.0000, 3411.8000,
    3412.8000, 3413.2000, 3414.0000, 3414.8000, 3415.8000, 3416.2000, 3417.0000, 3417.8000,
    3418.8000, 3419.2000, 3420.0000, 3420.8000, 3421.8000, 3422.3999, 3423.0000, 3423.8000,
    3424.8000, 3425.8000, 3426.8000, 3427.8000, 3428.8000, 3429.8000, 3430.6001, 3431.6001,
    3432.6001, 3433.3999, 3434.3999, 3435.3999, 3436.2000, 3437.2000, 3438.2000, 3439.0000,
    3440.0000, 3441.2000, 3442.3999, 3443.8000, 3444.8000, 3445.8000, 3447.0000, 3448.0000,
    3449.0000, 3450.0000, 3451.3999, 3452.6001, 3453.8000, 3454.8000, 3455.8000, 3456.8000,
    3457.3999, 3458.0000, 3459.0000, 3459.8000, 3460.6001, 3461.0000, 3462.0000, 3462.8000,
    3463.6001, 3464.0000, 3465.0000, 3465.8000, 3466.6001, 3467.0000, 3468.0000, 3468.8000,
    3469.6001, 3470.0000, 3471.0000, 3471.8000, 3472.8000, 3473.6001, 3474.3999, 3475.2000,
    3476.0000, 3477.0000, 3478.0000, 3478.8000, 3479.8000, 3480.8000, 3481.8000, 3482.6001,
    3483.3999, 3484.2000, 3485.0000, 3486.0000, 3487.0000, 3487.8000, 3488.8000, 3489.8000,
    3490.8000, 3491.8000, 3492.6001, 3493.6001, 3494.3999, 3495.3999, 3496.2000, 3497.0000,
    3498.0000, 3499.0000, 3500.0000, 3501.0000, 3502.0000, 3502.8000, 3503.8000, 3504.8000,
    3505.6001, 3506.2000, 3507.0000, 3507.8000, 3508.8000, 3509.6001, 3510.2000, 3511.0000,
    3512.0000, 3512.8000, 3513.6001, 3514.2000, 3515.0000, 3516.0000, 3516.8000, 3517.6001,
    3518.2000, 3519.0000, 3520.0000, 3520.8000, 3521.6001, 3522.0000, 3523.0000, 3523.8000,
    3524.6001, 3525.0000, 3526.0000, 3526.8000, 3527.6001, 3528.0000, 3529.0000, 3529.8000,
    3530.6001, 3531.0000, 3532.0000, 3532.8000, 3533.6001, 3534.0000, 3535.0000, 3535.8000,
    3536.8000, 3537.8000, 3538.8000, 3539.8000, 3540.8000, 3541.8000, 3543.0000, 3544.0000,
    3545.0000, 3546.0000, 3547.0000, 3548.0000, 3549.0000, 3550.2000, 3551.3999, 3552.3999,
    3553.0000, 3554.0000, 3554.8000, 3555.8000, 3556.3999, 3557.0000, 3558.0000, 3558.8000,
    3559.8000, 3560.6001, 3561.2000, 3562.0000, 3562.8000, 3563.8000, 3564.6001, 3565.2000,
    3566.0000, 3567.0000, 3567.8000, 3568.6001, 3569.0000, 3570.0000, 3570.8000, 3571.3999,
    3572.0000, 3572.8000, 3573.6001, 3574.0000, 3575.0000, 3575.8000, 3576.3999, 3577.0000,
    3577.8000, 3578.6001, 3579.0000, 3580.0000, 3580.8000, 3581.3999, 3582.0000, 3582.8000,
    3583.6001, 3584.0000, 3585.0000, 3585.8000, 3586.8000, 3587.6001, 3588.2000, 3589.0000,
    3590.0000, 3590.8000, 3591.6001, 3592.2000, 3593.0000, 3594.0000, 3594.8000, 3595.8000,
    3596.3999, 3597.0000, 3598.0000, 3598.8000, 3599.8000, 3600.6001, 3601.2000, 3602.0000,
    3603.0000, 3603.8000, 3604.8000, 3605.3999, 3606.0000, 3607.0000, 3607.8000, 3608.8000,
    3609.6001, 3610.2000, 3611.0000, 3612.0000, 3612.8000, 3613.8000, 3614.3999, 3615.2000,
    3616.0000, 3617.0000, 3617.8000, 3618.8000, 3619.3999, 3620.0000, 3621.0000, 3621.8000,
    3622.8000, 3623.6001, 3624.3999, 3625.0000, 3626.0000, 3626.8000, 3627.8000, 3628.6001,
    3629.2000, 3630.0000, 3631.0000, 3631.8000, 3632.8000, 3633.3999, 3634.0000, 3635.0000,
    3635.8000, 3636.8000, 3637.3999, 3638.0000, 3639.0000, 3639.8000, 3640.8000, 3641.2000,
    3642.0000, 3643.0000, 3643.8000, 3644.6001, 3645.2000, 3646.0000, 3646.8000, 3647.8000,
    3648.3999, 3649.0000, 3649.8000, 3650.6001, 3651.0000, 3651.8000, 3652.8000, 3653.2000,
    3654.0000, 3654.8000, 3655.2000, 3656.0000, 3656.8000, 3657.3999, 3658.0000, 3658.8000,
    3659.3999, 3660.0000, 3660.8000, 3661.6001, 3662.0000, 3662.8000, 3663.8000, 3664.2000,
    3665.0000, 3665.8000, 3666.6001, 3667.2000, 3668.0000, 3668.8000, 3669.6001, 3670.2000,
    3671.0000, 3671.8000, 3672.6001, 3673.2000, 3674.0000, 3674.8000, 3675.6001, 3676.2000,
    3677.0000, 3677.8000, 3678.6001, 3679.2000, 3680.0000, 3680.8000, 3681.6001, 3682.0000,
    3683.0000, 3683.8000, 3684.3999, 3685.0000, 3685.8000, 3686.8000, 3687.2000, 3688.0000,
    3688.8000, 3689.6001, 3690.0000, 3691.0000, 3691.8000, 3692.3999, 3693.0000, 3693.8000,
    3694.8000, 3695.2000, 3696.0000, 3697.0000, 3697.8000, 3698.8000, 3699.8000, 3700.6001,
    3701.2000, 3702.0000, 3703.0000, 3704.0000, 3704.8000, 3705.8000, 3706.8000, 3707.6001,
    3708.3999, 3709.2000, 3710.0000, 3711.0000, 3712.0000, 3712.8000, 3713.3999, 3714.0000,
    3714.8000, 3715.8000, 3716.2000, 3717.0000, 3717.8000, 3718.6001, 3719.0000, 3720.0000,
    3720.8000, 3721.3999, 3722.0000, 3722.8000, 3723.8000, 3724.2000, 3725.0000, 3725.8000,
    3726.6001, 3727.0000, 3728.0000, 3728.8000, 3729.0000, 3729.8000, 3730.6001, 3731.0000,
    3731.8000, 3732.3999, 3733.0000, 3733.8000, 3734.2000, 3735.0000, 3735.8000, 3736.2000,
    3737.0000, 3737.6001, 3738.0000, 3738.8000, 3739.6001, 3740.0000, 3740.8000, 3741.3999,
    3742.0000, 3742.8000, 3743.2000, 3744.0000, 3744.6001, 3745.0000, 3745.8000, 3746.0000,
    3746.8000, 3747.2000, 3747.8000, 3748.3999, 3749.0000, 3749.8000, 3750.0000, 3750.8000,
    3751.0000, 3751.8000, 3752.2000, 3753.0000, 3753.6001, 3754.0000, 3754.8000, 3755.0000,
    3755.8000, 3756.2000, 3756.8000, 3757.3999, 3758.0000, 3758.6001, 3759.0000, 3759.8000,
    3760.0000, 3760.8000, 3761.8000, 3762.2000, 3763.0000, 3763.8000, 3764.6001, 3765.0000,
    3766.0000, 3766.8000, 3767.3999, 3768.0000, 3768.8000, 3769.6001, 3770.0000, 3771.0000,
    3771.8000, 3772.3999, 3773.0000, 3773.8000, 3774.8000, 3775.2000, 3776.0000, 3776.8000,
    3777.2000, 3778.0000, 3778.8000, 3779.0000, 3779.8000, 3780.6001, 3781.0000, 3781.8000,
    3782.3999, 3783.0000, 3783.8000, 3784.2000, 3785.0000, 3785.6001, 3786.0000, 3786.8000,
    3787.3999, 3788.0000, 3788.8000, 3789.2000, 3790.0000, 3790.8000, 3791.0000, 3791.8000,
    3792.6001, 3793.0000, 3793.8000, 3794.3999, 3795.0000, 3795.8000, 3796.2000, 3797.0000,
    3797.8000, 3798.0000, 3798.8000, 3799.6001, 3800.0000, 3800.8000, 3801.3999, 3802.0000,
    3802.8000, 3803.2000, 3804.0000, 3804.8000, 3805.0000, 3805.8000, 3806.6001, 3807.0000,
    3807.8000, 3808.3999, 3809.0000, 3810.0000, 3810.8000, 3811.8000, 3812.6001, 3813.2000,
    3814.0000, 3814.8000, 3815.8000, 3816.6001, 3817.2000, 3818.0000, 3819.0000, 3819.8000,
    3820.6001, 3821.2000, 3822.0000, 3823.0000, 3823.8000, 3824.6001, 3825.0000, 3825.8000,
    3826.2000, 3827.0000, 3827.6001, 3828.0000, 3828.8000, 3829.3999, 3830.0000, 3830.8000,
    3831.0000, 3831.8000, 3832.6001, 3833.0000, 3833.8000, 3834.2000, 3835.0000, 3835.6001,
    3836.0000, 3836.8000, 3837.3999, 3838.0000, 3838.8000, 3839.0000, 3839.8000, 3840.6001,
    3841.0000, 3841.8000, 3842.6001, 3843.0000, 3843.8000, 3844.6001, 3845.0000, 3845.8000,
    3846.6001, 3847.0000, 3847.8000, 3848.6001, 3849.0000, 3849.8000, 3850.6001, 3851.0000,
    3851.8000, 3852.3999, 3853.0000, 3853.8000, 3854.3999, 3855.0000, 3855.8000, 3856.3999,
    3857.0000, 3857.8000, 3858.6001, 3859.0000, 3859.8000, 3860.6001, 3861.0000, 3861.8000,
    3862.8000, 3863.0000, 3864.0000, 3864.8000, 3865.2000, 3866.0000, 3866.8000, 3867.2000,
    3868.0000, 3868.8000, 3869.2000, 3870.0000, 3870.8000, 3871.3999, 3872.0000, 3872.8000,
    3873.2000, 3873.8000, 3874.6001, 3875.0000, 3875.8000, 3876.2000, 3877.0000, 3877.6001,
    3878.0000, 3878.8000, 3879.3999, 3880.0000, 3880.8000, 3881.0000, 3881.8000, 3882.3999,
    3883.0000, 3883.8000, 3884.0000, 3884.8000, 3885.3999, 3886.0000, 3886.8000, 3887.2000,
    3887.8000, 3888.6001, 3889.0000, 3889.8000, 3890.0000, 3890.8000, 3891.3999, 3892.0000,
    3892.6001, 3893.0000, 3893.8000, 3894.0000, 3894.8000, 3895.3999, 3896.0000, 3896.8000,
    3897.0000, 3897.8000, 3898.2000, 3898.8000, 3899.6001, 3900.0000, 3900.8000, 3901.0000,
    3901.8000, 3902.2000, 3903.0000, 3903.6001, 3904.0000, 3904.8000, 3905.3999, 3906.0000,
    3906.8000, 3907.6001, 3908.0000, 3908.8000, 3909.8000, 3910.2000, 3911.0000, 3911.8000,
    3912.3999, 3913.0000, 3913.8000, 3914.3999, 3915.0000, 3915.8000, 3916.6001, 3917.0000,
    3917.8000, 3918.8000, 3919.2000, 3920.0000, 3920.8000, 3921.2000, 3921.8000, 3922.6001,
    3923.0000, 3923.8000, 3924.3999, 3925.0000, 3925.8000, 3926.2000, 3927.0000, 3927.8000,
    3928.2000, 3928.8000, 3929.6001, 3930.0000, 3930.8000, 3931.3999, 3932.0000, 3932.8000,
    3933.3999, 3934.0000, 3934.8000, 3935.2000, 3935.8000, 3936.6001, 3937.0000, 3937.6001,
    3938.0000, 3938.6001, 3939.0000, 3939.8000, 3940.0000, 3940.8000, 3941.0000, 3941.8000,
    3942.0000, 3942.8000, 3943.0000, 3943.8000, 3944.2000, 3944.8000, 3945.2000, 3945.8000,
    3946.2000, 3946.8000, 3947.3999, 3947.8000, 3948.3999, 3949.0000, 3949.6001, 3950.0000,
    3950.6001, 3951.0000, 3951.6001, 3952.0000, 3952.8000, 3953.6001, 3954.2000, 3955.0000,
    3955.8000, 3956.8000, 3957.3999, 3958.0000, 3959.0000, 3959.8000, 3960.6001, 3961.2000,
    3962.0000, 3962.8000, 3963.8000, 3964.3999, 3965.0000, 3966.0000, 3966.8000, 3967.6001,
    3968.0000, 3968.8000, 3969.2000, 3969.8000, 3970.2000, 3970.8000, 3971.2000, 3971.8000,
    3972.3999, 3973.0000, 3973.3999, 3974.0000, 3974.6001, 3975.0000, 3975.6001, 3976.0000,
    3976.6001, 3977.0000, 3977.8000, 3978.0000, 3978.8000, 3979.0000, 3979.8000, 3980.0000,
    3980.8000, 3981.0000, 3981.8000, 3982.2000, 3982.8000, 3983.2000, 3983.8000, 3984.3999,
    3985.0000, 3985.6001, 3986.0000, 3986.8000, 3987.0000, 3987.8000, 3988.0000, 3988.8000,
    3989.2000, 3989.8000, 3990.3999, 3991.0000, 3991.6001, 3992.0000, 3992.8000, 3993.0000,
    3993.8000, 3994.2000, 3994.8000, 3995.3999, 3996.0000, 3996.6001, 3997.0000, 3997.8000,
    3998.0000, 3998.8000, 3999.2000, 3999.8000, 4000.3999, 4001.0000, 4001.8000, 4002.8000,
    4003.2000, 4004.0000, 4004.8000, 4005.6001, 4006.2000, 4007.0000, 4007.8000, 4008.6001,
    4009.0000, 4010.0000, 4010.8000, 4011.3999, 4012.0000, 4012.8000, 4013.8000, 4014.2000,
    4015.0000, 4015.8000, 4016.3999, 4017.0000, 4017.6001, 4018.0000, 4018.6001, 4019.0000,
    4019.8000, 4020.0000, 4020.8000, 4021.0000, 4021.8000, 4022.0000, 4022.8000, 4023.2000,
    4023.8000, 4024.2000, 4024.8000, 4025.3999, 4026.0000, 4026.3999, 4027.0000, 4027.6001,
    4028.0000, 4028.6001, 4029.0000, 4029.8000, 4030.0000, 4030.8000, 4031.0000, 4031.8000,
    4032.0000, 4032.8000, 4033.2000, 4033.8000, 4034.2000, 4034.8000, 4035.2000, 4035.8000,
    4036.3999, 4036.8000, 4037.3999, 4038.0000, 4038.3999, 4039.0000, 4039.6001, 4040.0000,
    4040.6001, 4041.0000, 4041.6001, 4042.0000, 4042.8000, 4043.0000, 4043.8000, 4044.0000,
    4044.8000, 4045.0000, 4045.8000, 4046.0000, 4046.8000, 4047.2000, 4047.8000, 4048.2000,
    4049.0000, 4049.8000, 4050.3999, 4051.0000, 4051.8000, 4052.8000, 4053.2000, 4054.0000,
    4054.8000, 4055.3999, 4056.0000, 4056.8000, 4057.6001, 4058.0000, 4058.8000, 4059.8000,
    4060.2000, 4061.0000, 4061.8000, 4062.3999, 4063.0000, 4063.8000, 4066.8000, 4072.0000,
    4077.60,
];

Вывод температуры на OLED-дисплей

В этой главе нет ничего нового. В большинстве реальных сценариев вы не будете подключены к компьютеру и захотите выводить температуру на что-то вроде OLED-дисплея. Поэтому вместо вывода температуры в консоль системы мы выведем её на OLED-дисплей.

В предыдущей главе я не стал использовать OLED-дисплей, чтобы вы могли выполнить упражнение с термистором, даже если его у вас тогда не было. Однако для этого упражнения OLED-дисплей понадобится.

Предварительные требования

Подробно останавливаться на коде я не буду, так как большую его часть мы уже разобрали в разделе «Предварительные требования».

Требования к оборудованию

  • OLED-дисплей: (0,96 дюйма, I2C/IIC, 4 вывода, разрешение 128x64, чип SSD1306)
  • Провода-перемычки
  • Термистор NTC 103: 10 кОм, 5 мм, дисковый в эпоксидном покрытии
  • Резистор 10 кОм: используется вместе с термистором для формирования делителя напряжения

Схема подключения OLED и термистора к ESP32

ESP32, OLED и термистор

Подключение термистора к ESP32

Это та же схема, что и в предыдущей главе, поэтому можете оставить подключение как есть.

  1. Один вывод термистора подключён к GND.
  2. Другой вывод термистора подключён к GPIO13, который является выводом ADC2 ESP32.
  3. Резистор 10 кОм подключается последовательно с термистором, образуя делитель напряжения между термистором и 3.3V. Поэтому один вывод резистора подключите к 3.3V, а другой — к GPIO13.

Подключение OLED к ESP32

Вывод GPIO 18 подключим к SCL, а вывод GPIO 23 — к SDA. Вывод VCC OLED-дисплея подключим к выводу 3.3V ESP32, а вывод Ground — к земле ESP32.

Вывод комнатной температуры на OLED-дисплей с ESP32 и Rust

Для этого проекта включим поддержку async (Embassy). Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 oled-temperature

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features»
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

Мы будем использовать крейт ssd1306, математическую библиотеку «libm» и heapless для форматирования строк.

libm = "0.2.15"
nb = "1.1.0"
ssd1306 = { version = "0.10.0", features = ["async"] }
embedded-graphics = "0.8.1"
heapless = "0.9.1"

Настройка

Нужно инициализировать и настроить и OLED-дисплей, и термистор. Код инициализации здесь опущен, чтобы не повторяться: сосредоточимся только на дополнительных шагах.

Необходимые дополнительные импорты

#![allow(unused)]
fn main() {
// АЦП
use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Связанное с embedded-graphics
use embedded_graphics::prelude::*;
use embedded_graphics::{
    mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
    pixelcolor::BinaryColor,
    prelude::Point,
    text::{Baseline, Text},
};

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Heapless
use heapless::String;
// Для записи в heapless
use core::fmt::Write;
}

Затем создайте буфер на основе строки heapless, чтобы динамически формировать строку для вывода температуры:

#![allow(unused)]
fn main() {
let mut buffer: String<64> = String::new();
}

Здесь мы определили строку heapless ёмкостью 64 байта.

Главный цикл

Мы запишем значение температуры в буфер. Буфер heapless нужен здесь для форматирования строки. Без него можно было бы вывести сырое значение температуры, но буфер heapless позволяет отформатировать строку перед выводом. После форматирования температуры мы выводим её на OLED-дисплей.

#![allow(unused)]
fn main() {
loop {
    buffer.clear();
    display.clear_buffer();

    let adc_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
    // esp_println::println!("ADC: {}", adc_value);
    let adc_value: f64 = ADC_LUT[adc_value as usize];
    // esp_println::println!("Corrected ADC: {}", adc_value);

    let current_res = adc_to_resistance(adc_value);
    // esp_println::println!("R2: {}", current_res);

    let temperature_kelvin = calculate_temperature(current_res, B_VALUE);
    let temperature_celsius = kelvin_to_celsius(temperature_kelvin);

    write!(buffer, "Temperature: {:.2} C", temperature_celsius).unwrap();
    Text::with_baseline(&buffer, Point::new(0, 16), text_style, Baseline::Top)
        .draw(&mut display)
        .unwrap();

    display.flush().await.unwrap();
    Timer::after(Duration::from_secs(1)).await;
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку oled-temperature.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/oled-temperature

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// АЦП
use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Связанное с embedded-graphics
use embedded_graphics::prelude::*;
use embedded_graphics::{
    mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
    pixelcolor::BinaryColor,
    prelude::Point,
    text::{Baseline, Text},
};

// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};

// Heapless
use heapless::String;
// Для записи в heapless
use core::fmt::Write;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

const fn kelvin_to_celsius(kelvin: f64) -> f64 {
    kelvin - 273.15
}

const fn celsius_to_kelvin(celsius: f64) -> f64 {
    celsius + 273.15
}

const ADC_MAX: f64 = 4095.0; // 4095 для 12-битного АЦП
const B_VALUE: f64 = 3950.0;
const REF_TEMP: f64 = 25.0; // Опорная температура 25°C
const REF_RES: f64 = 10_000.0; // Сопротивление термистора при опорной температуре (25°C)
const REF_TEMP_K: f64 = celsius_to_kelvin(REF_TEMP);
const R1_RES: f64 = REF_RES; // 10_000.0 Ом

fn adc_to_resistance(adc_value: f64) -> f64 {
    let x: f64 = adc_value / (ADC_MAX - adc_value);
    R1_RES * x
}

// Уравнение B для перевода сопротивления в температуру
fn calculate_temperature(current_res: f64, b_val: f64) -> f64 {
    let ln_value = libm::log(current_res / REF_RES); // Используем libm для `no_std`
    let inv_t = (1.0 / REF_TEMP_K) + ((1.0 / b_val) * ln_value);
    1.0 / inv_t
}

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    // Настройка АЦП для термистора
    let adc_pin = peripherals.GPIO13;
    let mut adc2_config = AdcConfig::new();
    let mut pin = adc2_config.enable_pin(adc_pin, Attenuation::_11dB);
    let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);

    // Настраиваем дисплей
    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let interface = I2CDisplayInterface::new(i2c_bus);

    // Инициализируем дисплей
    let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
        .into_buffered_graphics_mode();
    display.init().await.unwrap();

    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_6X10)
        .text_color(BinaryColor::On)
        .build();

    // Heapless для форматирования строк
    let mut buffer: String<64> = String::new();

    loop {
        buffer.clear();
        display.clear_buffer();

        let adc_value: u16 = nb::block!(adc2.read_oneshot(&mut pin)).unwrap();
        // esp_println::println!("ADC: {}", adc_value);
        let adc_value: f64 = ADC_LUT[adc_value as usize];
        // esp_println::println!("Corrected ADC: {}", adc_value);

        let current_res = adc_to_resistance(adc_value);
        // esp_println::println!("R2: {}", current_res);

        let temperature_kelvin = calculate_temperature(current_res, B_VALUE);
        let temperature_celsius = kelvin_to_celsius(temperature_kelvin);

        write!(buffer, "Temperature: {:.2} C", temperature_celsius).unwrap();
        Text::with_baseline(&buffer, Point::new(0, 16), text_style, Baseline::Top)
            .draw(&mut display)
            .unwrap();

        display.flush().await.unwrap();

        Timer::after(Duration::from_secs(1)).await;
    }
}

// Таблица поиска (LUT) для исправления нелинейности АЦП
// https://github.com/e-tinkers/esp32-adc-calibrate
// https://www.e-tinkers.com/2019/10/using-a-thermistor-with-arduino-and-unexpected-esp32-adc-non-linearity/
const ADC_LUT: &[f64; 4096] = &[
    0.0, 21.8000, 32.0000, 33.8000, 35.0000, 36.4000, 37.8000, 39.0000, 40.8000, 42.0000, 43.6000,
    44.8000, 46.2000, 47.8000, 49.0000, 50.0000, 51.2000, 52.6000, 53.8000, 54.8000, 56.0000,
    57.0000, 58.4000, 59.8000, 60.8000, 62.0000, 63.0000, 64.2000, 65.4000, 66.6000, 67.8000,
    68.8000, 69.8000, 70.8000, 72.0000, 73.0000, 74.0000, 75.2000, 76.4000, 77.6000, 78.8000,
    79.8000, 80.8000, 81.8000, 82.8000, 84.0000, 85.0000, 86.0000, 87.0000, 88.0000, 89.0000,
    90.0000, 91.0000, 92.2000, 93.2000, 94.4000, 95.4000, 96.6000, 97.8000, 98.8000, 99.8000,
    101.0000, 102.0000, 103.2000, 104.4000, 105.6000, 106.8000, 107.8000, 109.0000, 110.0000,
    111.0000, 112.4000, 113.8000, 115.4000, 117.0000, 118.6000, 120.0000, 121.6000, 123.0000,
    124.8000, 126.0000, 127.8000, 128.8000, 129.8000, 130.8000, 131.8000, 132.6000, 133.6000,
    134.4000, 135.2000, 136.2000, 137.0000, 138.0000, 139.0000, 140.0000, 141.0000, 141.8000,
    142.8000, 143.8000, 144.8000, 146.0000, 147.2000, 148.8000, 149.8000, 151.0000, 152.2000,
    153.6000, 154.8000, 156.0000, 157.2000, 158.6000, 159.8000, 160.8000, 161.8000, 162.8000,
    163.8000, 164.8000, 165.8000, 166.8000, 167.8000, 168.8000, 169.8000, 170.8000, 171.8000,
    172.8000, 173.8000, 174.8000, 175.8000, 176.8000, 177.8000, 179.0000, 180.0000, 181.4000,
    182.6000, 183.8000, 184.8000, 186.0000, 187.0000, 188.2000, 189.6000, 190.8000, 191.8000,
    193.0000, 194.0000, 195.4000, 196.8000, 197.8000, 199.0000, 200.0000, 201.2000, 202.6000,
    203.8000, 205.0000, 206.0000, 207.2000, 208.4000, 209.6000, 210.6000, 211.8000, 212.8000,
    213.8000, 214.8000, 215.8000, 216.8000, 217.8000, 218.8000, 220.0000, 221.0000, 222.0000,
    223.0000, 224.0000, 225.0000, 226.0000, 227.0000, 228.0000, 229.0000, 230.2000, 231.2000,
    232.2000, 233.4000, 234.4000, 235.4000, 236.4000, 237.6000, 238.6000, 239.6000, 240.8000,
    242.0000, 243.4000, 244.8000, 246.0000, 247.4000, 248.8000, 250.0000, 251.6000, 252.8000,
    254.0000, 255.6000, 256.8000, 257.8000, 258.8000, 259.6000, 260.6000, 261.4000, 262.4000,
    263.4000, 264.2000, 265.2000, 266.0000, 267.0000, 268.0000, 269.0000, 270.0000, 271.0000,
    272.0000, 273.0000, 274.6000, 275.8000, 277.0000, 278.4000, 279.8000, 281.0000, 282.2000,
    283.8000, 284.8000, 286.0000, 287.6000, 288.8000, 289.8000, 290.8000, 291.8000, 292.8000,
    293.8000, 294.8000, 295.8000, 296.8000, 297.8000, 298.8000, 299.8000, 300.8000, 301.8000,
    302.8000, 303.8000, 304.8000, 305.8000, 307.0000, 308.0000, 309.0000, 310.0000, 311.0000,
    312.2000, 313.4000, 314.6000, 315.8000, 316.8000, 317.8000, 318.8000, 320.0000, 321.0000,
    322.4000, 323.8000, 325.0000, 326.2000, 327.6000, 328.8000, 330.0000, 331.4000, 332.8000,
    334.0000, 335.0000, 336.4000, 337.6000, 338.8000, 339.8000, 340.8000, 342.0000, 343.0000,
    344.0000, 345.2000, 346.4000, 347.6000, 348.8000, 349.8000, 350.8000, 352.0000, 353.0000,
    354.0000, 355.0000, 356.0000, 357.0000, 358.2000, 359.4000, 360.4000, 361.6000, 362.6000,
    363.8000, 364.8000, 365.8000, 366.8000, 367.8000, 368.8000, 370.0000, 371.0000, 372.0000,
    373.0000, 374.0000, 375.0000, 376.2000, 377.4000, 378.4000, 379.6000, 380.6000, 381.8000,
    382.8000, 383.8000, 384.8000, 386.0000, 387.2000, 388.6000, 389.8000, 390.8000, 392.0000,
    393.2000, 394.6000, 395.8000, 396.8000, 398.0000, 399.2000, 400.6000, 401.8000, 403.0000,
    404.8000, 406.0000, 407.2000, 408.8000, 410.0000, 411.2000, 412.8000, 414.0000, 415.4000,
    416.8000, 417.8000, 418.6000, 419.6000, 420.6000, 421.4000, 422.4000, 423.4000, 424.2000,
    425.2000, 426.2000, 427.0000, 428.0000, 429.0000, 430.0000, 431.0000, 432.0000, 433.6000,
    435.0000, 436.6000, 438.0000, 439.4000, 441.0000, 442.4000, 443.8000, 445.4000, 446.8000,
    448.2000, 449.0000, 449.8000, 450.8000, 451.4000, 452.0000, 453.0000, 453.8000, 454.8000,
    455.2000, 456.0000, 457.0000, 457.8000, 458.6000, 459.2000, 460.0000, 460.8000, 461.8000,
    462.4000, 463.0000, 464.0000, 465.0000, 466.0000, 467.2000, 468.6000, 469.8000, 470.8000,
    471.8000, 473.0000, 474.0000, 475.2000, 476.4000, 477.8000, 478.8000, 479.8000, 481.0000,
    482.8000, 484.0000, 485.4000, 486.8000, 488.0000, 489.8000, 491.0000, 492.4000, 493.8000,
    495.2000, 496.6000, 497.4000, 498.2000, 499.0000, 500.0000, 501.0000, 502.0000, 502.8000,
    503.8000, 504.8000, 505.8000, 506.6000, 507.4000, 508.2000, 509.0000, 510.0000, 511.0000,
    512.0000, 513.0000, 514.2000, 515.6000, 516.8000, 518.0000, 519.0000, 520.4000, 521.8000,
    522.8000, 524.0000, 525.2000, 526.6000, 527.8000, 529.0000, 530.0000, 531.2000, 532.6000,
    533.8000, 535.0000, 536.0000, 537.2000, 538.6000, 539.8000, 541.0000, 542.0000, 543.2000,
    544.6000, 545.6000, 546.8000, 547.8000, 548.8000, 549.8000, 550.8000, 551.8000, 552.8000,
    553.8000, 555.0000, 556.0000, 557.0000, 558.0000, 559.0000, 560.0000, 561.4000, 562.8000,
    563.8000, 565.0000, 566.4000, 567.8000, 568.8000, 570.0000, 571.4000, 572.8000, 573.8000,
    575.0000, 576.2000, 577.2000, 578.4000, 579.4000, 580.4000, 581.4000, 582.4000, 583.4000,
    584.6000, 585.6000, 586.6000, 587.6000, 588.6000, 589.6000, 590.6000, 591.8000, 592.8000,
    593.8000, 595.0000, 596.0000, 597.0000, 598.2000, 599.6000, 600.8000, 601.8000, 603.0000,
    604.0000, 605.0000, 606.2000, 607.6000, 608.6000, 609.0000, 610.0000, 610.8000, 611.6000,
    612.2000, 613.0000, 613.8000, 614.8000, 615.2000, 616.0000, 616.8000, 617.8000, 618.4000,
    619.0000, 620.0000, 620.8000, 621.4000, 622.0000, 623.0000, 623.8000, 624.8000, 625.8000,
    626.8000, 627.8000, 629.0000, 630.0000, 631.0000, 632.0000, 633.0000, 634.2000, 635.4000,
    636.6000, 637.6000, 638.8000, 639.8000, 641.0000, 642.0000, 643.6000, 644.8000, 646.0000,
    647.4000, 648.8000, 650.0000, 651.4000, 652.8000, 654.0000, 655.2000, 656.6000, 657.8000,
    658.8000, 659.8000, 660.8000, 661.8000, 663.0000, 664.0000, 665.0000, 666.0000, 667.2000,
    668.2000, 669.4000, 670.6000, 671.6000, 672.8000, 674.0000, 675.6000, 676.8000, 678.0000,
    679.8000, 681.0000, 682.2000, 683.8000, 685.0000, 686.4000, 687.8000, 689.0000, 690.0000,
    691.0000, 692.0000, 693.0000, 694.0000, 695.0000, 696.2000, 697.2000, 698.2000, 699.4000,
    700.4000, 701.6000, 702.6000, 703.8000, 704.6000, 705.4000, 706.2000, 707.0000, 708.0000,
    708.8000, 709.8000, 710.8000, 711.6000, 712.4000, 713.0000, 714.0000, 715.0000, 715.8000,
    716.8000, 717.8000, 718.6000, 719.2000, 720.0000, 721.8000, 723.0000, 724.4000, 725.8000,
    727.0000, 728.8000, 730.0000, 731.4000, 732.8000, 734.0000, 735.6000, 736.8000, 737.8000,
    738.8000, 739.8000, 740.8000, 742.0000, 743.0000, 744.0000, 745.0000, 746.0000, 747.0000,
    748.0000, 749.0000, 750.0000, 751.0000, 752.2000, 753.6000, 754.8000, 756.0000, 757.4000,
    758.8000, 760.0000, 761.2000, 762.6000, 763.8000, 765.0000, 766.4000, 767.8000, 768.8000,
    770.0000, 771.0000, 772.0000, 773.0000, 774.2000, 775.4000, 776.6000, 777.8000, 778.8000,
    779.8000, 780.8000, 782.0000, 783.0000, 784.0000, 785.2000, 786.6000, 787.8000, 789.0000,
    790.0000, 791.4000, 792.8000, 793.8000, 795.0000, 796.0000, 797.4000, 798.8000, 799.8000,
    801.0000, 802.0000, 803.0000, 804.4000, 805.6000, 806.8000, 807.8000, 808.8000, 810.0000,
    811.0000, 812.0000, 813.2000, 814.4000, 815.8000, 816.8000, 817.8000, 818.8000, 819.8000,
    820.8000, 821.8000, 822.8000, 823.8000, 824.8000, 825.8000, 826.8000, 827.8000, 828.8000,
    829.8000, 830.8000, 831.8000, 833.0000, 834.0000, 835.0000, 836.2000, 837.4000, 838.6000,
    839.8000, 840.8000, 841.8000, 843.0000, 844.0000, 845.0000, 846.2000, 847.4000, 848.8000,
    849.8000, 851.0000, 852.8000, 853.8000, 855.0000, 856.8000, 857.8000, 859.0000, 860.8000,
    861.8000, 863.0000, 864.6000, 865.4000, 866.4000, 867.2000, 868.2000, 869.0000, 870.0000,
    871.0000, 872.0000, 873.0000, 874.0000, 875.0000, 876.0000, 876.8000, 877.8000, 878.8000,
    879.8000, 880.8000, 881.8000, 883.0000, 884.0000, 885.0000, 886.0000, 887.0000, 888.2000,
    889.2000, 890.4000, 891.6000, 892.6000, 893.8000, 894.8000, 895.8000, 897.0000, 898.2000,
    899.8000, 901.0000, 902.0000, 903.6000, 904.8000, 906.0000, 907.6000, 908.8000, 910.0000,
    911.4000, 912.8000, 913.4000, 914.2000, 915.0000, 916.0000, 916.8000, 917.8000, 918.8000,
    919.6000, 920.2000, 921.0000, 922.0000, 923.0000, 923.8000, 924.8000, 925.6000, 926.4000,
    927.2000, 928.0000, 929.4000, 930.8000, 932.0000, 933.2000, 934.8000, 935.8000, 937.0000,
    938.4000, 939.8000, 941.0000, 942.2000, 943.8000, 944.8000, 946.0000, 947.4000, 948.8000,
    950.0000, 951.0000, 952.6000, 953.8000, 955.0000, 956.2000, 957.8000, 958.8000, 960.0000,
    961.0000, 962.2000, 963.4000, 964.6000, 965.8000, 966.8000, 967.8000, 969.0000, 970.0000,
    971.0000, 972.0000, 973.2000, 974.4000, 975.6000, 976.6000, 977.4000, 978.2000, 979.2000,
    980.0000, 981.0000, 982.0000, 983.0000, 983.8000, 984.8000, 985.8000, 986.8000, 987.6000,
    988.4000, 989.2000, 990.0000, 991.0000, 992.0000, 993.0000, 994.0000, 995.4000, 996.6000,
    997.8000, 998.8000, 1000.0000, 1001.0000, 1002.0000, 1003.2000, 1004.4000, 1005.8000,
    1006.8000, 1007.8000, 1008.8000, 1009.0000, 1009.8000, 1010.2000, 1010.8000, 1011.4000,
    1012.0000, 1012.6000, 1013.0000, 1013.8000, 1014.0000, 1014.8000, 1015.4000, 1016.0000,
    1016.6000, 1017.0000, 1017.8000, 1018.0000, 1018.8000, 1019.2000, 1019.8000, 1020.6000,
    1021.0000, 1021.8000, 1022.0000, 1022.8000, 1023.2000, 1023.8000, 1024.8000, 1026.0000,
    1027.6000, 1028.8000, 1030.2000, 1031.8000, 1033.0000, 1034.4000, 1035.8000, 1037.0000,
    1038.8000, 1040.0000, 1041.0000, 1042.0000, 1043.0000, 1044.0000, 1045.0000, 1046.0000,
    1047.0000, 1048.0000, 1049.0000, 1050.0000, 1051.0000, 1052.0000, 1053.0000, 1054.0000,
    1055.0000, 1056.0000, 1057.0000, 1058.2000, 1059.6000, 1060.8000, 1061.8000, 1063.0000,
    1064.0000, 1065.2000, 1066.6000, 1067.8000, 1068.8000, 1070.0000, 1071.0000, 1072.2000,
    1073.0000, 1074.0000, 1075.0000, 1076.0000, 1077.0000, 1078.0000, 1078.8000, 1079.8000,
    1080.8000, 1081.8000, 1082.8000, 1083.8000, 1084.6000, 1085.6000, 1086.4000, 1087.2000,
    1088.2000, 1089.6000, 1090.8000, 1092.0000, 1093.0000, 1094.4000, 1095.8000, 1096.8000,
    1098.0000, 1099.2000, 1100.8000, 1101.8000, 1103.0000, 1104.0000, 1105.2000, 1106.4000,
    1107.6000, 1108.8000, 1109.8000, 1110.8000, 1112.0000, 1113.0000, 1114.0000, 1115.2000,
    1116.4000, 1117.6000, 1118.8000, 1119.8000, 1120.8000, 1121.8000, 1123.0000, 1124.0000,
    1125.0000, 1126.0000, 1127.0000, 1128.2000, 1129.4000, 1130.6000, 1131.6000, 1132.8000,
    1133.8000, 1134.8000, 1135.8000, 1136.8000, 1137.8000, 1138.8000, 1139.6000, 1140.4000,
    1141.2000, 1142.0000, 1143.0000, 1144.0000, 1144.8000, 1145.8000, 1146.8000, 1147.6000,
    1148.4000, 1149.2000, 1150.0000, 1151.0000, 1152.0000, 1153.0000, 1154.0000, 1155.0000,
    1156.0000, 1157.2000, 1158.4000, 1159.6000, 1160.8000, 1161.8000, 1162.8000, 1163.8000,
    1164.8000, 1166.0000, 1167.0000, 1168.0000, 1169.6000, 1170.8000, 1172.0000, 1173.8000,
    1175.0000, 1176.2000, 1177.8000, 1179.0000, 1180.4000, 1181.8000, 1183.0000, 1184.4000,
    1185.0000, 1186.0000, 1186.8000, 1187.6000, 1188.2000, 1189.0000, 1189.8000, 1190.8000,
    1191.6000, 1192.0000, 1193.0000, 1193.8000, 1194.8000, 1195.4000, 1196.0000, 1197.0000,
    1197.8000, 1198.6000, 1199.2000, 1200.0000, 1201.2000, 1202.6000, 1203.8000, 1205.0000,
    1206.2000, 1207.6000, 1208.8000, 1210.0000, 1211.0000, 1212.6000, 1213.8000, 1215.0000,
    1216.0000, 1217.2000, 1218.2000, 1219.4000, 1220.6000, 1221.6000, 1222.8000, 1223.8000,
    1224.8000, 1225.8000, 1226.8000, 1227.8000, 1229.0000, 1230.0000, 1231.0000, 1232.0000,
    1233.0000, 1234.2000, 1235.4000, 1236.6000, 1237.8000, 1238.8000, 1239.8000, 1241.0000,
    1242.0000, 1243.0000, 1244.0000, 1245.2000, 1246.4000, 1247.6000, 1248.8000, 1249.8000,
    1250.6000, 1251.6000, 1252.6000, 1253.6000, 1254.4000, 1255.4000, 1256.4000, 1257.4000,
    1258.2000, 1259.2000, 1260.2000, 1261.2000, 1262.0000, 1263.0000, 1264.0000, 1265.6000,
    1266.8000, 1268.0000, 1269.4000, 1270.8000, 1272.0000, 1273.4000, 1274.8000, 1276.0000,
    1277.2000, 1278.8000, 1280.0000, 1281.0000, 1282.0000, 1283.0000, 1284.0000, 1285.0000,
    1285.8000, 1286.8000, 1287.8000, 1288.8000, 1289.8000, 1290.8000, 1291.8000, 1292.8000,
    1293.8000, 1294.8000, 1295.8000, 1296.8000, 1298.0000, 1299.2000, 1300.6000, 1301.8000,
    1302.8000, 1304.0000, 1305.2000, 1306.4000, 1307.8000, 1308.8000, 1310.0000, 1311.0000,
    1312.4000, 1313.6000, 1314.8000, 1315.8000, 1316.8000, 1317.8000, 1319.0000, 1320.0000,
    1321.0000, 1322.0000, 1323.2000, 1324.2000, 1325.4000, 1326.6000, 1327.8000, 1328.8000,
    1329.8000, 1330.8000, 1332.0000, 1333.0000, 1334.0000, 1335.0000, 1336.2000, 1337.4000,
    1338.6000, 1339.6000, 1340.8000, 1341.8000, 1342.8000, 1343.8000, 1345.0000, 1346.2000,
    1347.6000, 1348.8000, 1349.8000, 1351.0000, 1352.0000, 1353.4000, 1354.8000, 1355.8000,
    1357.0000, 1358.0000, 1359.4000, 1360.8000, 1361.8000, 1362.8000, 1363.8000, 1364.8000,
    1365.8000, 1366.8000, 1367.8000, 1368.8000, 1369.8000, 1370.8000, 1371.8000, 1372.8000,
    1373.8000, 1374.8000, 1375.8000, 1376.8000, 1377.8000, 1379.0000, 1380.2000, 1381.6000,
    1382.8000, 1383.8000, 1385.0000, 1386.2000, 1387.6000, 1388.8000, 1389.8000, 1391.0000,
    1392.0000, 1393.2000, 1394.4000, 1395.6000, 1396.6000, 1397.8000, 1398.8000, 1399.8000,
    1400.8000, 1401.8000, 1403.0000, 1404.0000, 1405.0000, 1406.0000, 1407.0000, 1408.2000,
    1409.2000, 1410.4000, 1411.4000, 1412.6000, 1413.6000, 1414.8000, 1415.8000, 1416.8000,
    1417.8000, 1418.8000, 1419.8000, 1420.8000, 1422.0000, 1423.0000, 1424.0000, 1425.0000,
    1426.0000, 1427.0000, 1428.0000, 1428.8000, 1429.8000, 1430.8000, 1431.8000, 1432.8000,
    1433.8000, 1434.8000, 1435.8000, 1436.8000, 1437.8000, 1438.8000, 1439.8000, 1440.8000,
    1441.8000, 1443.0000, 1444.2000, 1445.6000, 1446.8000, 1448.0000, 1449.0000, 1450.6000,
    1451.8000, 1453.0000, 1454.0000, 1455.4000, 1456.8000, 1457.8000, 1459.0000, 1460.0000,
    1461.4000, 1462.6000, 1463.8000, 1465.0000, 1466.0000, 1467.2000, 1468.6000, 1469.8000,
    1470.8000, 1472.0000, 1473.0000, 1474.0000, 1475.2000, 1476.4000, 1477.6000, 1478.8000,
    1479.8000, 1480.8000, 1481.8000, 1483.0000, 1484.0000, 1485.0000, 1486.2000, 1487.4000,
    1488.6000, 1489.6000, 1490.8000, 1491.8000, 1492.8000, 1493.8000, 1494.8000, 1496.0000,
    1497.0000, 1498.0000, 1499.0000, 1500.0000, 1501.2000, 1502.4000, 1503.6000, 1504.6000,
    1505.8000, 1506.8000, 1508.0000, 1509.0000, 1510.0000, 1511.2000, 1512.4000, 1513.8000,
    1514.8000, 1515.8000, 1517.0000, 1518.0000, 1519.0000, 1520.2000, 1521.0000, 1522.0000,
    1523.0000, 1523.8000, 1524.8000, 1525.6000, 1526.4000, 1527.2000, 1528.0000, 1529.0000,
    1529.8000, 1530.8000, 1531.8000, 1532.6000, 1533.4000, 1534.2000, 1535.0000, 1536.0000,
    1537.0000, 1538.2000, 1539.4000, 1540.8000, 1541.8000, 1543.0000, 1544.0000, 1545.2000,
    1546.6000, 1547.8000, 1548.8000, 1550.0000, 1551.0000, 1552.2000, 1553.6000, 1554.8000,
    1556.0000, 1557.0000, 1558.4000, 1559.8000, 1560.8000, 1562.0000, 1563.0000, 1564.4000,
    1565.8000, 1566.8000, 1568.0000, 1569.0000, 1570.0000, 1571.0000, 1571.8000, 1572.8000,
    1573.8000, 1574.8000, 1575.8000, 1576.8000, 1577.8000, 1578.8000, 1579.6000, 1580.6000,
    1581.6000, 1582.4000, 1583.4000, 1584.4000, 1585.8000, 1587.0000, 1588.8000, 1590.0000,
    1591.2000, 1592.8000, 1594.0000, 1595.6000, 1597.0000, 1598.2000, 1599.8000, 1600.8000,
    1601.8000, 1602.6000, 1603.2000, 1604.0000, 1605.0000, 1605.8000, 1606.8000, 1607.8000,
    1608.6000, 1609.2000, 1610.0000, 1611.0000, 1612.0000, 1612.8000, 1613.8000, 1614.6000,
    1615.2000, 1616.0000, 1617.4000, 1618.6000, 1619.8000, 1620.8000, 1622.0000, 1623.0000,
    1624.4000, 1625.8000, 1626.8000, 1627.8000, 1629.0000, 1630.0000, 1631.4000, 1632.6000,
    1633.8000, 1634.8000, 1635.8000, 1637.0000, 1638.0000, 1639.0000, 1640.4000, 1641.6000,
    1642.8000, 1643.8000, 1644.8000, 1646.0000, 1647.0000, 1648.0000, 1649.2000, 1650.2000,
    1651.4000, 1652.6000, 1653.8000, 1654.8000, 1655.8000, 1656.8000, 1658.0000, 1659.0000,
    1660.0000, 1661.0000, 1662.0000, 1663.2000, 1664.6000, 1665.8000, 1667.0000, 1668.2000,
    1669.8000, 1671.0000, 1672.0000, 1673.6000, 1674.8000, 1676.0000, 1677.4000, 1678.8000,
    1680.0000, 1681.0000, 1682.0000, 1682.8000, 1683.8000, 1684.8000, 1685.8000, 1686.8000,
    1687.8000, 1688.8000, 1689.6000, 1690.6000, 1691.4000, 1692.4000, 1693.2000, 1694.0000,
    1695.0000, 1696.0000, 1697.8000, 1699.2000, 1700.8000, 1702.6000, 1704.0000, 1705.8000,
    1707.2000, 1708.8000, 1710.6000, 1712.0000, 1712.8000, 1713.6000, 1714.0000, 1715.0000,
    1715.8000, 1716.2000, 1717.0000, 1717.8000, 1718.4000, 1719.0000, 1719.8000, 1720.6000,
    1721.0000, 1722.0000, 1722.8000, 1723.2000, 1724.0000, 1724.8000, 1725.4000, 1726.0000,
    1726.8000, 1727.6000, 1728.2000, 1729.2000, 1730.2000, 1731.2000, 1732.2000, 1733.2000,
    1734.2000, 1735.2000, 1736.2000, 1737.4000, 1738.4000, 1739.4000, 1740.4000, 1741.4000,
    1742.4000, 1743.4000, 1744.4000, 1745.4000, 1746.4000, 1747.4000, 1748.4000, 1749.4000,
    1750.4000, 1751.4000, 1752.4000, 1753.4000, 1754.4000, 1755.4000, 1756.4000, 1757.4000,
    1758.4000, 1759.4000, 1760.6000, 1761.8000, 1763.0000, 1764.0000, 1765.4000, 1766.8000,
    1767.8000, 1769.0000, 1770.4000, 1771.8000, 1772.8000, 1774.0000, 1775.2000, 1776.6000,
    1777.8000, 1778.8000, 1780.0000, 1781.0000, 1782.0000, 1783.2000, 1784.4000, 1785.8000,
    1786.8000, 1787.8000, 1789.0000, 1790.0000, 1791.0000, 1792.2000, 1793.6000, 1794.8000,
    1795.8000, 1797.0000, 1798.0000, 1799.2000, 1800.4000, 1801.8000, 1802.8000, 1803.8000,
    1805.0000, 1806.0000, 1807.2000, 1808.6000, 1809.8000, 1810.8000, 1811.8000, 1812.8000,
    1813.8000, 1815.0000, 1816.0000, 1817.0000, 1818.0000, 1819.0000, 1820.2000, 1821.2000,
    1822.4000, 1823.6000, 1824.8000, 1825.8000, 1826.8000, 1828.0000, 1829.0000, 1830.4000,
    1831.8000, 1832.8000, 1833.8000, 1835.0000, 1836.0000, 1837.4000, 1838.6000, 1839.8000,
    1840.8000, 1842.0000, 1843.2000, 1844.6000, 1845.8000, 1847.0000, 1848.0000, 1849.4000,
    1850.8000, 1851.8000, 1853.0000, 1854.0000, 1855.4000, 1856.6000, 1857.6000, 1858.6000,
    1859.6000, 1860.4000, 1861.4000, 1862.4000, 1863.4000, 1864.2000, 1865.2000, 1866.2000,
    1867.2000, 1868.0000, 1869.0000, 1870.0000, 1871.0000, 1872.0000, 1873.2000, 1874.6000,
    1875.8000, 1876.8000, 1878.0000, 1879.0000, 1880.2000, 1881.6000, 1882.8000, 1883.8000,
    1885.0000, 1886.0000, 1887.2000, 1888.6000, 1889.6000, 1890.6000, 1891.8000, 1892.8000,
    1893.8000, 1894.8000, 1895.8000, 1896.8000, 1897.8000, 1898.8000, 1900.0000, 1901.0000,
    1902.0000, 1903.0000, 1904.0000, 1905.0000, 1906.0000, 1907.0000, 1908.0000, 1909.0000,
    1910.0000, 1911.2000, 1912.2000, 1913.2000, 1914.2000, 1915.4000, 1916.4000, 1917.4000,
    1918.4000, 1919.6000, 1920.8000, 1921.8000, 1923.0000, 1924.0000, 1925.6000, 1926.8000,
    1928.0000, 1929.0000, 1930.4000, 1931.8000, 1932.8000, 1934.0000, 1935.2000, 1936.4000,
    1937.6000, 1938.6000, 1939.6000, 1940.6000, 1941.6000, 1942.8000, 1943.8000, 1944.8000,
    1945.8000, 1946.8000, 1947.8000, 1948.8000, 1949.8000, 1950.8000, 1951.8000, 1952.8000,
    1953.8000, 1955.0000, 1956.0000, 1957.0000, 1958.0000, 1959.2000, 1960.4000, 1961.4000,
    1962.6000, 1963.8000, 1964.8000, 1965.8000, 1966.8000, 1968.0000, 1969.0000, 1970.0000,
    1971.0000, 1972.0000, 1973.0000, 1974.2000, 1975.2000, 1976.4000, 1977.4000, 1978.6000,
    1979.8000, 1980.8000, 1981.8000, 1982.8000, 1983.8000, 1985.0000, 1986.2000, 1987.8000,
    1989.0000, 1990.0000, 1991.8000, 1992.8000, 1994.0000, 1995.6000, 1996.8000, 1998.0000,
    1999.4000, 2000.8000, 2001.8000, 2002.8000, 2004.0000, 2005.0000, 2006.0000, 2007.2000,
    2008.4000, 2009.6000, 2010.8000, 2011.8000, 2012.8000, 2013.8000, 2015.0000, 2016.0000,
    2017.0000, 2018.0000, 2019.2000, 2020.2000, 2021.4000, 2022.4000, 2023.6000, 2024.8000,
    2025.8000, 2026.8000, 2027.8000, 2028.8000, 2029.8000, 2031.0000, 2032.0000, 2032.8000,
    2033.0000, 2033.6000, 2034.0000, 2034.6000, 2035.0000, 2035.4000, 2036.0000, 2036.4000,
    2036.8000, 2037.2000, 2037.8000, 2038.2000, 2038.8000, 2039.2000, 2039.8000, 2040.0000,
    2040.8000, 2041.0000, 2041.8000, 2042.0000, 2042.8000, 2043.0000, 2043.6000, 2044.0000,
    2044.6000, 2045.0000, 2045.6000, 2046.0000, 2046.4000, 2046.8000, 2047.4000, 2047.8000,
    2048.6001, 2049.8000, 2050.8000, 2052.0000, 2053.2000, 2054.6001, 2055.8000, 2056.8000,
    2058.0000, 2059.0000, 2060.3999, 2061.8000, 2062.8000, 2064.0000, 2065.0000, 2066.0000,
    2066.8000, 2067.8000, 2068.8000, 2069.8000, 2070.8000, 2071.8000, 2072.8000, 2073.8000,
    2074.8000, 2075.6001, 2076.6001, 2077.6001, 2078.3999, 2079.3999, 2080.2000, 2081.2000,
    2082.2000, 2083.0000, 2084.0000, 2085.0000, 2086.0000, 2087.0000, 2088.0000, 2089.0000,
    2090.0000, 2091.0000, 2092.0000, 2092.8000, 2093.8000, 2094.8000, 2095.8000, 2097.0000,
    2098.0000, 2099.6001, 2100.8000, 2102.0000, 2103.3999, 2104.8000, 2106.0000, 2107.3999,
    2108.8000, 2110.0000, 2111.2000, 2112.8000, 2113.8000, 2115.0000, 2116.6001, 2117.8000,
    2119.0000, 2120.2000, 2121.8000, 2123.0000, 2124.0000, 2125.6001, 2126.8000, 2128.0000,
    2129.0000, 2130.0000, 2130.8000, 2131.8000, 2132.8000, 2133.8000, 2134.8000, 2135.6001,
    2136.3999, 2137.2000, 2138.0000, 2139.0000, 2140.0000, 2141.0000, 2142.0000, 2142.8000,
    2143.8000, 2144.8000, 2146.0000, 2147.8000, 2149.0000, 2150.0000, 2151.8000, 2153.0000,
    2154.2000, 2155.8000, 2157.0000, 2158.2000, 2159.8000, 2160.8000, 2161.8000, 2163.0000,
    2164.0000, 2165.0000, 2166.0000, 2167.0000, 2168.0000, 2169.2000, 2170.2000, 2171.3999,
    2172.6001, 2173.6001, 2174.8000, 2175.8000, 2176.8000, 2177.8000, 2178.8000, 2180.0000,
    2181.0000, 2182.0000, 2183.0000, 2184.0000, 2185.2000, 2186.3999, 2187.3999, 2188.6001,
    2189.8000, 2190.8000, 2191.8000, 2193.0000, 2194.0000, 2195.6001, 2196.8000, 2198.0000,
    2199.3999, 2200.8000, 2202.0000, 2203.2000, 2204.8000, 2205.8000, 2207.0000, 2208.3999,
    2209.6001, 2210.8000, 2211.8000, 2212.8000, 2213.8000, 2215.0000, 2216.0000, 2217.0000,
    2218.2000, 2219.3999, 2220.6001, 2221.8000, 2222.8000, 2223.8000, 2224.8000, 2225.6001,
    2226.3999, 2227.0000, 2228.0000, 2228.8000, 2229.8000, 2230.6001, 2231.3999, 2232.0000,
    2233.0000, 2233.8000, 2234.8000, 2235.6001, 2236.2000, 2237.0000, 2238.0000, 2238.8000,
    2239.8000, 2240.8000, 2241.8000, 2242.8000, 2243.8000, 2244.8000, 2245.8000, 2246.8000,
    2247.8000, 2248.8000, 2249.8000, 2250.8000, 2251.8000, 2252.8000, 2253.8000, 2254.8000,
    2255.8000, 2256.8000, 2257.8000, 2258.8000, 2260.0000, 2261.0000, 2262.2000, 2263.3999,
    2264.8000, 2265.8000, 2266.8000, 2268.0000, 2269.0000, 2270.2000, 2271.3999, 2272.6001,
    2273.6001, 2274.3999, 2275.3999, 2276.2000, 2277.2000, 2278.0000, 2279.0000, 2280.0000,
    2281.0000, 2282.0000, 2283.0000, 2284.0000, 2285.0000, 2286.0000, 2286.8000, 2287.8000,
    2289.0000, 2290.0000, 2291.0000, 2292.2000, 2293.6001, 2294.8000, 2295.8000, 2296.8000,
    2298.0000, 2299.0000, 2300.2000, 2301.6001, 2302.8000, 2303.8000, 2304.8000, 2306.0000,
    2307.0000, 2308.2000, 2309.6001, 2310.8000, 2311.8000, 2312.8000, 2314.0000, 2315.0000,
    2316.2000, 2317.6001, 2318.8000, 2319.8000, 2321.0000, 2322.0000, 2323.3999, 2324.8000,
    2325.8000, 2327.0000, 2328.2000, 2329.6001, 2330.8000, 2332.0000, 2333.0000, 2334.3999,
    2335.8000, 2336.8000, 2338.0000, 2339.0000, 2340.2000, 2341.6001, 2342.8000, 2343.8000,
    2345.0000, 2346.0000, 2347.0000, 2348.3999, 2349.6001, 2350.8000, 2351.8000, 2353.0000,
    2354.0000, 2355.0000, 2356.2000, 2357.3999, 2358.6001, 2359.8000, 2360.8000, 2361.8000,
    2363.0000, 2364.0000, 2365.0000, 2366.2000, 2367.3999, 2368.3999, 2369.3999, 2370.2000,
    2371.0000, 2372.0000, 2373.0000, 2374.0000, 2375.0000, 2375.8000, 2376.8000, 2377.8000,
    2378.8000, 2379.8000, 2380.6001, 2381.3999, 2382.3999, 2383.2000, 2384.2000, 2385.8000,
    2387.0000, 2388.2000, 2389.8000, 2391.0000, 2392.2000, 2393.8000, 2395.0000, 2396.2000,
    2397.8000, 2399.0000, 2400.2000, 2401.2000, 2402.2000, 2403.2000, 2404.2000, 2405.2000,
    2406.2000, 2407.2000, 2408.2000, 2409.2000, 2410.2000, 2411.2000, 2412.2000, 2413.2000,
    2414.2000, 2415.2000, 2416.2000, 2417.3999, 2418.8000, 2419.8000, 2420.8000, 2422.0000,
    2423.0000, 2424.2000, 2425.6001, 2426.8000, 2427.8000, 2429.0000, 2430.0000, 2431.2000,
    2432.3999, 2433.8000, 2435.0000, 2436.2000, 2437.8000, 2438.8000, 2440.0000, 2441.3999,
    2442.8000, 2444.0000, 2445.2000, 2446.8000, 2447.8000, 2449.0000, 2450.0000, 2451.0000,
    2452.0000, 2453.0000, 2454.2000, 2455.3999, 2456.3999, 2457.6001, 2458.8000, 2459.8000,
    2460.8000, 2461.8000, 2462.8000, 2463.8000, 2465.0000, 2466.0000, 2467.0000, 2468.2000,
    2469.3999, 2470.6001, 2471.8000, 2472.8000, 2473.8000, 2475.0000, 2476.0000, 2477.0000,
    2478.0000, 2479.2000, 2480.3999, 2481.3999, 2482.2000, 2483.2000, 2484.2000, 2485.2000,
    2486.0000, 2487.0000, 2488.0000, 2489.0000, 2490.0000, 2491.0000, 2492.0000, 2493.0000,
    2494.0000, 2495.0000, 2496.0000, 2497.0000, 2498.3999, 2499.8000, 2501.0000, 2502.0000,
    2503.6001, 2504.8000, 2506.0000, 2507.3999, 2508.8000, 2510.0000, 2511.0000, 2512.3999,
    2513.6001, 2514.8000, 2515.8000, 2516.8000, 2517.8000, 2519.0000, 2520.0000, 2521.0000,
    2522.0000, 2523.2000, 2524.3999, 2525.6001, 2526.8000, 2527.8000, 2528.8000, 2529.8000,
    2531.0000, 2532.0000, 2533.0000, 2534.2000, 2535.3999, 2536.6001, 2537.8000, 2538.8000,
    2539.8000, 2541.0000, 2542.0000, 2543.0000, 2544.2000, 2545.0000, 2546.0000, 2547.0000,
    2548.0000, 2549.0000, 2549.8000, 2550.8000, 2551.8000, 2552.8000, 2553.8000, 2554.6001,
    2555.3999, 2556.3999, 2557.2000, 2558.0000, 2559.0000, 2560.0000, 2561.3999, 2562.8000,
    2564.0000, 2565.3999, 2566.8000, 2568.0000, 2569.6001, 2570.8000, 2572.0000, 2573.6001,
    2574.8000, 2576.0000, 2577.0000, 2578.0000, 2579.0000, 2580.0000, 2581.0000, 2582.0000,
    2583.0000, 2584.0000, 2585.0000, 2586.0000, 2587.0000, 2587.8000, 2588.8000, 2589.8000,
    2590.8000, 2591.8000, 2592.8000, 2594.0000, 2595.0000, 2596.2000, 2597.3999, 2598.8000,
    2599.8000, 2600.8000, 2602.0000, 2603.0000, 2604.2000, 2605.3999, 2606.6001, 2607.8000,
    2609.0000, 2610.0000, 2611.8000, 2612.8000, 2614.0000, 2615.6001, 2616.8000, 2618.0000,
    2619.6001, 2620.8000, 2622.0000, 2623.6001, 2624.8000, 2625.8000, 2626.8000, 2627.8000,
    2628.8000, 2629.8000, 2630.8000, 2631.8000, 2632.8000, 2633.8000, 2634.6001, 2635.6001,
    2636.6001, 2637.6001, 2638.6001, 2639.6001, 2640.8000, 2641.8000, 2643.0000, 2644.0000,
    2645.6001, 2646.8000, 2648.0000, 2649.0000, 2650.2000, 2651.8000, 2652.8000, 2654.0000,
    2655.0000, 2656.3999, 2657.3999, 2658.3999, 2659.3999, 2660.3999, 2661.3999, 2662.2000,
    2663.2000, 2664.2000, 2665.2000, 2666.2000, 2667.2000, 2668.2000, 2669.2000, 2670.0000,
    2671.0000, 2672.0000, 2673.3999, 2674.8000, 2675.8000, 2677.0000, 2678.0000, 2679.2000,
    2680.6001, 2681.8000, 2682.8000, 2684.0000, 2685.0000, 2686.3999, 2687.8000, 2688.8000,
    2689.8000, 2690.8000, 2692.0000, 2693.0000, 2694.0000, 2695.0000, 2696.2000, 2697.3999,
    2698.6001, 2699.8000, 2700.8000, 2701.8000, 2702.8000, 2704.0000, 2705.0000, 2706.0000,
    2707.0000, 2708.0000, 2709.0000, 2710.0000, 2711.0000, 2712.0000, 2713.0000, 2714.0000,
    2715.0000, 2716.0000, 2717.0000, 2718.2000, 2719.2000, 2720.2000, 2721.3999, 2722.8000,
    2723.8000, 2724.8000, 2726.0000, 2727.0000, 2728.0000, 2729.2000, 2730.3999, 2731.6001,
    2732.8000, 2733.8000, 2734.8000, 2736.0000, 2737.0000, 2738.0000, 2739.0000, 2740.0000,
    2741.0000, 2742.0000, 2743.2000, 2744.2000, 2745.3999, 2746.3999, 2747.3999, 2748.6001,
    2749.6001, 2750.8000, 2751.8000, 2752.8000, 2754.0000, 2755.0000, 2756.6001, 2757.8000,
    2759.0000, 2760.0000, 2761.6001, 2762.8000, 2764.0000, 2765.2000, 2766.6001, 2767.8000,
    2768.8000, 2769.8000, 2770.8000, 2771.8000, 2772.8000, 2774.0000, 2775.0000, 2776.0000,
    2777.0000, 2778.0000, 2779.0000, 2780.0000, 2781.0000, 2782.0000, 2783.0000, 2784.0000,
    2785.0000, 2786.6001, 2787.8000, 2789.0000, 2790.2000, 2791.6001, 2792.8000, 2794.0000,
    2795.2000, 2796.8000, 2797.8000, 2799.0000, 2800.6001, 2802.0000, 2803.8000, 2805.0000,
    2806.8000, 2808.2000, 2810.0000, 2811.6001, 2813.0000, 2814.8000, 2816.0000, 2816.8000,
    2817.6001, 2818.0000, 2818.8000, 2819.2000, 2820.0000, 2820.8000, 2821.0000, 2821.8000,
    2822.6001, 2823.0000, 2823.8000, 2824.2000, 2825.0000, 2825.8000, 2826.0000, 2826.8000,
    2827.6001, 2828.0000, 2828.8000, 2829.2000, 2830.0000, 2830.8000, 2831.0000, 2831.8000,
    2832.8000, 2833.8000, 2834.8000, 2835.8000, 2836.8000, 2837.8000, 2838.8000, 2839.8000,
    2840.8000, 2841.8000, 2842.8000, 2843.8000, 2844.8000, 2845.8000, 2846.8000, 2847.8000,
    2849.0000, 2850.2000, 2851.6001, 2852.8000, 2854.0000, 2855.3999, 2856.8000, 2858.0000,
    2859.0000, 2860.6001, 2861.8000, 2863.0000, 2864.2000, 2865.3999, 2866.6001, 2867.8000,
    2868.8000, 2869.8000, 2871.0000, 2872.0000, 2873.0000, 2874.2000, 2875.3999, 2876.6001,
    2877.8000, 2878.8000, 2879.8000, 2881.0000, 2882.0000, 2883.2000, 2884.6001, 2885.8000,
    2886.8000, 2888.0000, 2889.0000, 2890.2000, 2891.6001, 2892.8000, 2893.8000, 2895.0000,
    2896.0000, 2897.0000, 2898.0000, 2898.8000, 2899.8000, 2900.8000, 2901.8000, 2902.6001,
    2903.3999, 2904.2000, 2905.0000, 2906.0000, 2907.0000, 2908.0000, 2908.8000, 2909.8000,
    2910.8000, 2911.8000, 2912.8000, 2913.8000, 2915.0000, 2916.3999, 2917.8000, 2919.0000,
    2920.0000, 2921.3999, 2922.8000, 2924.0000, 2925.0000, 2926.6001, 2927.8000, 2928.8000,
    2929.8000, 2930.8000, 2931.8000, 2932.8000, 2933.8000, 2934.8000, 2935.8000, 2936.8000,
    2937.8000, 2938.8000, 2939.8000, 2940.8000, 2941.6001, 2942.6001, 2943.6001, 2944.6001,
    2945.8000, 2946.8000, 2948.0000, 2949.0000, 2950.2000, 2951.3999, 2952.8000, 2953.8000,
    2954.8000, 2956.0000, 2957.0000, 2958.2000, 2959.6001, 2960.8000, 2961.8000, 2962.8000,
    2963.8000, 2965.0000, 2966.0000, 2967.0000, 2968.0000, 2969.0000, 2970.2000, 2971.3999,
    2972.6001, 2973.8000, 2974.8000, 2975.8000, 2976.8000, 2977.8000, 2978.8000, 2980.0000,
    2981.0000, 2982.0000, 2983.0000, 2984.0000, 2985.0000, 2986.0000, 2987.2000, 2988.2000,
    2989.3999, 2990.3999, 2991.6001, 2992.8000, 2993.8000, 2994.8000, 2995.8000, 2996.8000,
    2998.0000, 2999.0000, 3000.0000, 3001.0000, 3002.2000, 3003.3999, 3004.6001, 3005.6001,
    3006.8000, 3007.8000, 3008.8000, 3010.0000, 3011.0000, 3012.2000, 3013.3999, 3014.6001,
    3015.8000, 3016.8000, 3018.0000, 3019.0000, 3020.2000, 3021.3999, 3022.8000, 3023.8000,
    3024.8000, 3026.0000, 3027.0000, 3028.0000, 3029.0000, 3030.2000, 3031.3999, 3032.6001,
    3033.8000, 3034.8000, 3035.8000, 3037.0000, 3038.0000, 3039.0000, 3040.0000, 3041.0000,
    3042.0000, 3043.0000, 3044.0000, 3045.0000, 3046.0000, 3047.0000, 3048.0000, 3049.0000,
    3050.0000, 3051.0000, 3051.8000, 3052.8000, 3053.8000, 3054.8000, 3055.8000, 3056.6001,
    3057.0000, 3057.8000, 3058.0000, 3058.8000, 3059.3999, 3060.0000, 3060.6001, 3061.0000,
    3061.8000, 3062.0000, 3062.8000, 3063.3999, 3064.0000, 3064.6001, 3065.0000, 3065.8000,
    3066.0000, 3066.8000, 3067.3999, 3068.0000, 3068.6001, 3069.0000, 3069.8000, 3070.0000,
    3070.8000, 3071.3999, 3072.0000, 3072.8000, 3074.0000, 3075.0000, 3076.0000, 3077.0000,
    3078.2000, 3079.3999, 3080.6001, 3081.8000, 3082.8000, 3083.8000, 3084.8000, 3086.0000,
    3087.0000, 3088.0000, 3089.0000, 3090.0000, 3091.2000, 3092.2000, 3093.3999, 3094.3999,
    3095.6001, 3096.6001, 3097.8000, 3098.8000, 3099.8000, 3100.8000, 3101.8000, 3102.8000,
    3103.8000, 3105.0000, 3106.0000, 3107.0000, 3108.0000, 3109.0000, 3110.0000, 3111.0000,
    3112.2000, 3113.2000, 3114.3999, 3115.6001, 3116.6001, 3117.8000, 3118.8000, 3119.8000,
    3120.8000, 3121.8000, 3123.0000, 3124.0000, 3125.0000, 3126.0000, 3127.2000, 3128.3999,
    3129.6001, 3130.8000, 3131.8000, 3132.8000, 3133.8000, 3135.0000, 3136.0000, 3137.0000,
    3138.0000, 3138.8000, 3139.8000, 3140.8000, 3141.8000, 3142.8000, 3143.6001, 3144.6001,
    3145.3999, 3146.3999, 3147.2000, 3148.0000, 3149.0000, 3150.0000, 3151.0000, 3152.0000,
    3153.0000, 3154.0000, 3155.3999, 3156.6001, 3157.8000, 3158.8000, 3160.0000, 3161.0000,
    3162.0000, 3163.3999, 3164.6001, 3165.8000, 3166.8000, 3168.0000, 3169.0000, 3170.0000,
    3171.0000, 3171.8000, 3172.8000, 3173.8000, 3174.8000, 3175.8000, 3176.8000, 3177.8000,
    3178.8000, 3179.8000, 3180.8000, 3181.8000, 3182.8000, 3183.8000, 3184.8000, 3185.8000,
    3187.0000, 3188.0000, 3189.2000, 3190.6001, 3191.8000, 3192.8000, 3194.0000, 3195.0000,
    3196.3999, 3197.8000, 3198.8000, 3200.0000, 3200.8000, 3201.8000, 3202.8000, 3203.8000,
    3204.6001, 3205.3999, 3206.2000, 3207.0000, 3208.0000, 3209.0000, 3209.8000, 3210.8000,
    3211.8000, 3212.6001, 3213.3999, 3214.2000, 3215.0000, 3216.0000, 3217.0000, 3218.0000,
    3218.8000, 3219.8000, 3220.8000, 3221.8000, 3222.6001, 3223.3999, 3224.2000, 3225.0000,
    3226.0000, 3227.0000, 3228.0000, 3229.0000, 3229.8000, 3230.8000, 3231.8000, 3232.8000,
    3234.0000, 3235.2000, 3236.8000, 3238.0000, 3239.2000, 3240.8000, 3241.8000, 3243.0000,
    3244.6001, 3245.8000, 3247.0000, 3248.3999, 3249.8000, 3251.0000, 3252.3999, 3253.8000,
    3255.0000, 3256.2000, 3257.8000, 3259.0000, 3260.2000, 3261.8000, 3263.0000, 3264.0000,
    3265.0000, 3265.8000, 3266.8000, 3267.6001, 3268.2000, 3269.0000, 3270.0000, 3270.8000,
    3271.6001, 3272.3999, 3273.0000, 3274.0000, 3274.8000, 3275.8000, 3276.3999, 3277.0000,
    3278.0000, 3278.8000, 3279.8000, 3280.6001, 3281.3999, 3282.2000, 3283.0000, 3284.0000,
    3285.0000, 3286.0000, 3286.8000, 3287.8000, 3288.8000, 3289.6001, 3290.3999, 3291.2000,
    3292.0000, 3293.0000, 3294.0000, 3295.0000, 3295.8000, 3296.8000, 3298.0000, 3299.0000,
    3300.0000, 3301.2000, 3302.3999, 3303.6001, 3304.8000, 3305.8000, 3306.8000, 3308.0000,
    3309.0000, 3310.0000, 3311.2000, 3312.3999, 3313.0000, 3314.0000, 3315.0000, 3315.8000,
    3316.8000, 3317.6001, 3318.3999, 3319.0000, 3320.0000, 3321.0000, 3321.8000, 3322.8000,
    3323.6001, 3324.2000, 3325.0000, 3326.0000, 3327.0000, 3327.8000, 3328.8000, 3329.3999,
    3330.0000, 3330.8000, 3331.8000, 3332.3999, 3333.0000, 3334.0000, 3334.8000, 3335.6001,
    3336.2000, 3337.0000, 3337.8000, 3338.8000, 3339.3999, 3340.0000, 3341.0000, 3341.8000,
    3342.6001, 3343.0000, 3344.0000, 3344.8000, 3345.8000, 3346.8000, 3347.6001, 3348.2000,
    3349.0000, 3350.0000, 3351.0000, 3351.8000, 3352.8000, 3353.6001, 3354.3999, 3355.2000,
    3356.0000, 3357.0000, 3357.8000, 3358.8000, 3359.8000, 3360.6001, 3361.8000, 3362.8000,
    3363.8000, 3364.8000, 3365.8000, 3366.8000, 3367.8000, 3369.0000, 3370.0000, 3371.0000,
    3372.0000, 3373.0000, 3374.0000, 3375.2000, 3376.2000, 3377.3999, 3378.3999, 3379.3999,
    3380.3999, 3381.6001, 3382.6001, 3383.6001, 3384.8000, 3385.8000, 3386.8000, 3387.8000,
    3388.8000, 3389.8000, 3390.8000, 3391.8000, 3392.8000, 3394.0000, 3395.0000, 3396.0000,
    3397.0000, 3398.2000, 3399.2000, 3400.3999, 3401.6001, 3402.8000, 3403.8000, 3404.8000,
    3405.8000, 3407.0000, 3408.0000, 3408.8000, 3409.8000, 3410.2000, 3411.0000, 3411.8000,
    3412.8000, 3413.2000, 3414.0000, 3414.8000, 3415.8000, 3416.2000, 3417.0000, 3417.8000,
    3418.8000, 3419.2000, 3420.0000, 3420.8000, 3421.8000, 3422.3999, 3423.0000, 3423.8000,
    3424.8000, 3425.8000, 3426.8000, 3427.8000, 3428.8000, 3429.8000, 3430.6001, 3431.6001,
    3432.6001, 3433.3999, 3434.3999, 3435.3999, 3436.2000, 3437.2000, 3438.2000, 3439.0000,
    3440.0000, 3441.2000, 3442.3999, 3443.8000, 3444.8000, 3445.8000, 3447.0000, 3448.0000,
    3449.0000, 3450.0000, 3451.3999, 3452.6001, 3453.8000, 3454.8000, 3455.8000, 3456.8000,
    3457.3999, 3458.0000, 3459.0000, 3459.8000, 3460.6001, 3461.0000, 3462.0000, 3462.8000,
    3463.6001, 3464.0000, 3465.0000, 3465.8000, 3466.6001, 3467.0000, 3468.0000, 3468.8000,
    3469.6001, 3470.0000, 3471.0000, 3471.8000, 3472.8000, 3473.6001, 3474.3999, 3475.2000,
    3476.0000, 3477.0000, 3478.0000, 3478.8000, 3479.8000, 3480.8000, 3481.8000, 3482.6001,
    3483.3999, 3484.2000, 3485.0000, 3486.0000, 3487.0000, 3487.8000, 3488.8000, 3489.8000,
    3490.8000, 3491.8000, 3492.6001, 3493.6001, 3494.3999, 3495.3999, 3496.2000, 3497.0000,
    3498.0000, 3499.0000, 3500.0000, 3501.0000, 3502.0000, 3502.8000, 3503.8000, 3504.8000,
    3505.6001, 3506.2000, 3507.0000, 3507.8000, 3508.8000, 3509.6001, 3510.2000, 3511.0000,
    3512.0000, 3512.8000, 3513.6001, 3514.2000, 3515.0000, 3516.0000, 3516.8000, 3517.6001,
    3518.2000, 3519.0000, 3520.0000, 3520.8000, 3521.6001, 3522.0000, 3523.0000, 3523.8000,
    3524.6001, 3525.0000, 3526.0000, 3526.8000, 3527.6001, 3528.0000, 3529.0000, 3529.8000,
    3530.6001, 3531.0000, 3532.0000, 3532.8000, 3533.6001, 3534.0000, 3535.0000, 3535.8000,
    3536.8000, 3537.8000, 3538.8000, 3539.8000, 3540.8000, 3541.8000, 3543.0000, 3544.0000,
    3545.0000, 3546.0000, 3547.0000, 3548.0000, 3549.0000, 3550.2000, 3551.3999, 3552.3999,
    3553.0000, 3554.0000, 3554.8000, 3555.8000, 3556.3999, 3557.0000, 3558.0000, 3558.8000,
    3559.8000, 3560.6001, 3561.2000, 3562.0000, 3562.8000, 3563.8000, 3564.6001, 3565.2000,
    3566.0000, 3567.0000, 3567.8000, 3568.6001, 3569.0000, 3570.0000, 3570.8000, 3571.3999,
    3572.0000, 3572.8000, 3573.6001, 3574.0000, 3575.0000, 3575.8000, 3576.3999, 3577.0000,
    3577.8000, 3578.6001, 3579.0000, 3580.0000, 3580.8000, 3581.3999, 3582.0000, 3582.8000,
    3583.6001, 3584.0000, 3585.0000, 3585.8000, 3586.8000, 3587.6001, 3588.2000, 3589.0000,
    3590.0000, 3590.8000, 3591.6001, 3592.2000, 3593.0000, 3594.0000, 3594.8000, 3595.8000,
    3596.3999, 3597.0000, 3598.0000, 3598.8000, 3599.8000, 3600.6001, 3601.2000, 3602.0000,
    3603.0000, 3603.8000, 3604.8000, 3605.3999, 3606.0000, 3607.0000, 3607.8000, 3608.8000,
    3609.6001, 3610.2000, 3611.0000, 3612.0000, 3612.8000, 3613.8000, 3614.3999, 3615.2000,
    3616.0000, 3617.0000, 3617.8000, 3618.8000, 3619.3999, 3620.0000, 3621.0000, 3621.8000,
    3622.8000, 3623.6001, 3624.3999, 3625.0000, 3626.0000, 3626.8000, 3627.8000, 3628.6001,
    3629.2000, 3630.0000, 3631.0000, 3631.8000, 3632.8000, 3633.3999, 3634.0000, 3635.0000,
    3635.8000, 3636.8000, 3637.3999, 3638.0000, 3639.0000, 3639.8000, 3640.8000, 3641.2000,
    3642.0000, 3643.0000, 3643.8000, 3644.6001, 3645.2000, 3646.0000, 3646.8000, 3647.8000,
    3648.3999, 3649.0000, 3649.8000, 3650.6001, 3651.0000, 3651.8000, 3652.8000, 3653.2000,
    3654.0000, 3654.8000, 3655.2000, 3656.0000, 3656.8000, 3657.3999, 3658.0000, 3658.8000,
    3659.3999, 3660.0000, 3660.8000, 3661.6001, 3662.0000, 3662.8000, 3663.8000, 3664.2000,
    3665.0000, 3665.8000, 3666.6001, 3667.2000, 3668.0000, 3668.8000, 3669.6001, 3670.2000,
    3671.0000, 3671.8000, 3672.6001, 3673.2000, 3674.0000, 3674.8000, 3675.6001, 3676.2000,
    3677.0000, 3677.8000, 3678.6001, 3679.2000, 3680.0000, 3680.8000, 3681.6001, 3682.0000,
    3683.0000, 3683.8000, 3684.3999, 3685.0000, 3685.8000, 3686.8000, 3687.2000, 3688.0000,
    3688.8000, 3689.6001, 3690.0000, 3691.0000, 3691.8000, 3692.3999, 3693.0000, 3693.8000,
    3694.8000, 3695.2000, 3696.0000, 3697.0000, 3697.8000, 3698.8000, 3699.8000, 3700.6001,
    3701.2000, 3702.0000, 3703.0000, 3704.0000, 3704.8000, 3705.8000, 3706.8000, 3707.6001,
    3708.3999, 3709.2000, 3710.0000, 3711.0000, 3712.0000, 3712.8000, 3713.3999, 3714.0000,
    3714.8000, 3715.8000, 3716.2000, 3717.0000, 3717.8000, 3718.6001, 3719.0000, 3720.0000,
    3720.8000, 3721.3999, 3722.0000, 3722.8000, 3723.8000, 3724.2000, 3725.0000, 3725.8000,
    3726.6001, 3727.0000, 3728.0000, 3728.8000, 3729.0000, 3729.8000, 3730.6001, 3731.0000,
    3731.8000, 3732.3999, 3733.0000, 3733.8000, 3734.2000, 3735.0000, 3735.8000, 3736.2000,
    3737.0000, 3737.6001, 3738.0000, 3738.8000, 3739.6001, 3740.0000, 3740.8000, 3741.3999,
    3742.0000, 3742.8000, 3743.2000, 3744.0000, 3744.6001, 3745.0000, 3745.8000, 3746.0000,
    3746.8000, 3747.2000, 3747.8000, 3748.3999, 3749.0000, 3749.8000, 3750.0000, 3750.8000,
    3751.0000, 3751.8000, 3752.2000, 3753.0000, 3753.6001, 3754.0000, 3754.8000, 3755.0000,
    3755.8000, 3756.2000, 3756.8000, 3757.3999, 3758.0000, 3758.6001, 3759.0000, 3759.8000,
    3760.0000, 3760.8000, 3761.8000, 3762.2000, 3763.0000, 3763.8000, 3764.6001, 3765.0000,
    3766.0000, 3766.8000, 3767.3999, 3768.0000, 3768.8000, 3769.6001, 3770.0000, 3771.0000,
    3771.8000, 3772.3999, 3773.0000, 3773.8000, 3774.8000, 3775.2000, 3776.0000, 3776.8000,
    3777.2000, 3778.0000, 3778.8000, 3779.0000, 3779.8000, 3780.6001, 3781.0000, 3781.8000,
    3782.3999, 3783.0000, 3783.8000, 3784.2000, 3785.0000, 3785.6001, 3786.0000, 3786.8000,
    3787.3999, 3788.0000, 3788.8000, 3789.2000, 3790.0000, 3790.8000, 3791.0000, 3791.8000,
    3792.6001, 3793.0000, 3793.8000, 3794.3999, 3795.0000, 3795.8000, 3796.2000, 3797.0000,
    3797.8000, 3798.0000, 3798.8000, 3799.6001, 3800.0000, 3800.8000, 3801.3999, 3802.0000,
    3802.8000, 3803.2000, 3804.0000, 3804.8000, 3805.0000, 3805.8000, 3806.6001, 3807.0000,
    3807.8000, 3808.3999, 3809.0000, 3810.0000, 3810.8000, 3811.8000, 3812.6001, 3813.2000,
    3814.0000, 3814.8000, 3815.8000, 3816.6001, 3817.2000, 3818.0000, 3819.0000, 3819.8000,
    3820.6001, 3821.2000, 3822.0000, 3823.0000, 3823.8000, 3824.6001, 3825.0000, 3825.8000,
    3826.2000, 3827.0000, 3827.6001, 3828.0000, 3828.8000, 3829.3999, 3830.0000, 3830.8000,
    3831.0000, 3831.8000, 3832.6001, 3833.0000, 3833.8000, 3834.2000, 3835.0000, 3835.6001,
    3836.0000, 3836.8000, 3837.3999, 3838.0000, 3838.8000, 3839.0000, 3839.8000, 3840.6001,
    3841.0000, 3841.8000, 3842.6001, 3843.0000, 3843.8000, 3844.6001, 3845.0000, 3845.8000,
    3846.6001, 3847.0000, 3847.8000, 3848.6001, 3849.0000, 3849.8000, 3850.6001, 3851.0000,
    3851.8000, 3852.3999, 3853.0000, 3853.8000, 3854.3999, 3855.0000, 3855.8000, 3856.3999,
    3857.0000, 3857.8000, 3858.6001, 3859.0000, 3859.8000, 3860.6001, 3861.0000, 3861.8000,
    3862.8000, 3863.0000, 3864.0000, 3864.8000, 3865.2000, 3866.0000, 3866.8000, 3867.2000,
    3868.0000, 3868.8000, 3869.2000, 3870.0000, 3870.8000, 3871.3999, 3872.0000, 3872.8000,
    3873.2000, 3873.8000, 3874.6001, 3875.0000, 3875.8000, 3876.2000, 3877.0000, 3877.6001,
    3878.0000, 3878.8000, 3879.3999, 3880.0000, 3880.8000, 3881.0000, 3881.8000, 3882.3999,
    3883.0000, 3883.8000, 3884.0000, 3884.8000, 3885.3999, 3886.0000, 3886.8000, 3887.2000,
    3887.8000, 3888.6001, 3889.0000, 3889.8000, 3890.0000, 3890.8000, 3891.3999, 3892.0000,
    3892.6001, 3893.0000, 3893.8000, 3894.0000, 3894.8000, 3895.3999, 3896.0000, 3896.8000,
    3897.0000, 3897.8000, 3898.2000, 3898.8000, 3899.6001, 3900.0000, 3900.8000, 3901.0000,
    3901.8000, 3902.2000, 3903.0000, 3903.6001, 3904.0000, 3904.8000, 3905.3999, 3906.0000,
    3906.8000, 3907.6001, 3908.0000, 3908.8000, 3909.8000, 3910.2000, 3911.0000, 3911.8000,
    3912.3999, 3913.0000, 3913.8000, 3914.3999, 3915.0000, 3915.8000, 3916.6001, 3917.0000,
    3917.8000, 3918.8000, 3919.2000, 3920.0000, 3920.8000, 3921.2000, 3921.8000, 3922.6001,
    3923.0000, 3923.8000, 3924.3999, 3925.0000, 3925.8000, 3926.2000, 3927.0000, 3927.8000,
    3928.2000, 3928.8000, 3929.6001, 3930.0000, 3930.8000, 3931.3999, 3932.0000, 3932.8000,
    3933.3999, 3934.0000, 3934.8000, 3935.2000, 3935.8000, 3936.6001, 3937.0000, 3937.6001,
    3938.0000, 3938.6001, 3939.0000, 3939.8000, 3940.0000, 3940.8000, 3941.0000, 3941.8000,
    3942.0000, 3942.8000, 3943.0000, 3943.8000, 3944.2000, 3944.8000, 3945.2000, 3945.8000,
    3946.2000, 3946.8000, 3947.3999, 3947.8000, 3948.3999, 3949.0000, 3949.6001, 3950.0000,
    3950.6001, 3951.0000, 3951.6001, 3952.0000, 3952.8000, 3953.6001, 3954.2000, 3955.0000,
    3955.8000, 3956.8000, 3957.3999, 3958.0000, 3959.0000, 3959.8000, 3960.6001, 3961.2000,
    3962.0000, 3962.8000, 3963.8000, 3964.3999, 3965.0000, 3966.0000, 3966.8000, 3967.6001,
    3968.0000, 3968.8000, 3969.2000, 3969.8000, 3970.2000, 3970.8000, 3971.2000, 3971.8000,
    3972.3999, 3973.0000, 3973.3999, 3974.0000, 3974.6001, 3975.0000, 3975.6001, 3976.0000,
    3976.6001, 3977.0000, 3977.8000, 3978.0000, 3978.8000, 3979.0000, 3979.8000, 3980.0000,
    3980.8000, 3981.0000, 3981.8000, 3982.2000, 3982.8000, 3983.2000, 3983.8000, 3984.3999,
    3985.0000, 3985.6001, 3986.0000, 3986.8000, 3987.0000, 3987.8000, 3988.0000, 3988.8000,
    3989.2000, 3989.8000, 3990.3999, 3991.0000, 3991.6001, 3992.0000, 3992.8000, 3993.0000,
    3993.8000, 3994.2000, 3994.8000, 3995.3999, 3996.0000, 3996.6001, 3997.0000, 3997.8000,
    3998.0000, 3998.8000, 3999.2000, 3999.8000, 4000.3999, 4001.0000, 4001.8000, 4002.8000,
    4003.2000, 4004.0000, 4004.8000, 4005.6001, 4006.2000, 4007.0000, 4007.8000, 4008.6001,
    4009.0000, 4010.0000, 4010.8000, 4011.3999, 4012.0000, 4012.8000, 4013.8000, 4014.2000,
    4015.0000, 4015.8000, 4016.3999, 4017.0000, 4017.6001, 4018.0000, 4018.6001, 4019.0000,
    4019.8000, 4020.0000, 4020.8000, 4021.0000, 4021.8000, 4022.0000, 4022.8000, 4023.2000,
    4023.8000, 4024.2000, 4024.8000, 4025.3999, 4026.0000, 4026.3999, 4027.0000, 4027.6001,
    4028.0000, 4028.6001, 4029.0000, 4029.8000, 4030.0000, 4030.8000, 4031.0000, 4031.8000,
    4032.0000, 4032.8000, 4033.2000, 4033.8000, 4034.2000, 4034.8000, 4035.2000, 4035.8000,
    4036.3999, 4036.8000, 4037.3999, 4038.0000, 4038.3999, 4039.0000, 4039.6001, 4040.0000,
    4040.6001, 4041.0000, 4041.6001, 4042.0000, 4042.8000, 4043.0000, 4043.8000, 4044.0000,
    4044.8000, 4045.0000, 4045.8000, 4046.0000, 4046.8000, 4047.2000, 4047.8000, 4048.2000,
    4049.0000, 4049.8000, 4050.3999, 4051.0000, 4051.8000, 4052.8000, 4053.2000, 4054.0000,
    4054.8000, 4055.3999, 4056.0000, 4056.8000, 4057.6001, 4058.0000, 4058.8000, 4059.8000,
    4060.2000, 4061.0000, 4061.8000, 4062.3999, 4063.0000, 4063.8000, 4066.8000, 4072.0000,
    4077.60,
];

Последовательный периферийный интерфейс (SPI)

В этом разделе мы узнаем, что такое SPI, и научимся использовать шины SPI ESP32.

Что такое SPI?

SPI расшифровывается как Serial Peripheral Interface (последовательный периферийный интерфейс). Это один из самых распространённых способов, которыми микроконтроллеры обмениваются данными с дисплеями, датчиками и SD-картами. Технически его называют последовательным, полнодуплексным и синхронным интерфейсом. Что же означают эти термины?

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

  • Полнодуплексный (full duplex) означает, что два устройства могут общаться друг с другом одновременно. Представьте телефонный разговор двух людей: оба могут говорить и слушать одновременно. Это и есть полный дуплекс. В отличие от этого полудуплекс похож на рацию: говорит только один человек, пока другой слушает, а затем они меняются ролями.

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

Контроллер и периферийное устройство

SPI использует модель «контроллер — периферийное устройство» (раньше её называли «ведущий — ведомый», master-slave). Контроллер — это устройство, которое начинает обмен и формирует сигнал тактирования. Периферийное устройство отвечает на команды контроллера.

В большинстве встраиваемых проектов ESP32 выступает в роли контроллера, а такие устройства, как дисплеи, датчики или SD-карты, — в роли периферийных.

шина SPI с одним периферийным устройством

Рисунок: одна шина SPI с контроллером и одним периферийным устройством

Обычно соединение использует четыре линии:

  • Линия SCK (Serial Clock) передаёт сигнал тактирования, который формирует контроллер. Этот сигнал синхронизирует контроллер и устройство во время передачи данных.

  • Линия MOSI (Master Out, Slave In) используется для отправки данных от контроллера к устройству. В некоторых даташитах эту линию могут обозначать как SDO (Serial Data Out).

  • Линия MISO (Master In, Slave Out) передаёт данные от устройства к контроллеру. В зависимости от устройства её также могут называть SDI (Serial Data In).

  • Наконец, линия CS (Chip Select) используется контроллером, чтобы выбрать, с каким устройством он хочет общаться. У каждого подключённого устройства обычно есть собственная линия CS. Когда контроллер переводит линию CS устройства в низкий уровень (активный), это устройство становится активным и готовым к обмену. В некоторой старой документации эту линию могут называть SS (Slave Select).

шина SPI с несколькими периферийными устройствами

Рисунок: одна шина SPI с контроллером и несколькими периферийными устройствами

Режимы SPI

SPI поддерживает четыре режима, пронумерованных от 0 до 3. Они определяют, когда данные читаются и записываются, в зависимости от уровня простоя тактового сигнала и фронта.

Пока не переживайте слишком сильно из-за деталей. Режим 0 — самый распространённый, и он подходит для большинства устройств.

Почему SPI?

SPI — отличный выбор, когда нужна быстрая и надёжная связь между микроконтроллером и периферийным устройством. Он заметно быстрее I²C или UART, прост в использовании и позволяет одновременно отправлять и принимать данные (полный дуплекс). Поэтому он идеально подходит для быстрых устройств, таких как дисплеи или SD-карты.

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

Ресурсы

Для более глубоких технических подробностей см.:

Использование SPI в экосистеме embedded Rust

В предыдущем разделе мы узнали, что такое SPI и как работает модель «контроллер — периферийное устройство». Теперь посмотрим, как эти понятия применяются в экосистеме embedded Rust.

Экосистема embedded Rust построена модульно и с расчётом на повторное использование. Это значит, что код, написанный для одного микроконтроллера, можно перенести на другой с минимальными изменениями. Ключ к такой гибкости — трейты, определённые в крейте embedded-hal.

SPI в embedded-hal

Крейт embedded-hal определяет стандартные трейты для работы с SPI, чтобы драйверы и библиотеки можно было писать обобщённо. Два важных трейта для SPI:

  • SpiBus: представляет полный контроль над шиной SPI, включая линии SCK, MOSI и MISO. Его обязан реализовать крейт HAL микроконтроллера. Например, крейт esp-hal реализует SpiBus. Если интересно, можно посмотреть реализацию здесь.

  • SpiDevice: представляет доступ к одному устройству SPI, которое может делить шину с другими. Он управляет линией выбора микросхемы (CS) и гарантирует, что устройство выбрано перед обменом и освобождается после него.

Драйверы, не зависящие от платформы

Представьте, что вы пишете драйвер для датчика или дисплея, который обменивается данными по SPI. Вы не хотите привязывать код к конкретному микроконтроллеру, например Raspberry Pi Pico или ESP32. Вместо этого можно написать драйвер обобщённо, используя трейты embedded-hal.

Пока ваш драйвер зависит только от трейтов SpiDevice или SpiBus, он может работать на любой платформе, которая реализует эти трейты, — будь то STM32, nRF или ESP32.

Совместное использование шины SPI

Во многих проектах несколько устройств SPI используют одну и ту же шину. Например, дисплей, SD-карта и датчик температуры могут быть подключены к одним и тем же линиям MOSI, MISO и SCK. Различает их только линия выбора микросхемы (CS).

одна шина SPI с несколькими устройствами

Рисунок: одна шина SPI с контроллером и несколькими периферийными устройствами

Если отдать полный контроль над шиной SPI одному драйверу (через SpiBus), остальные не смогут её использовать. Поэтому нам нужен способ безопасно делить шину SPI между несколькими устройствами.

Здесь на помощь приходит SpiDevice: он позволяет каждому драйверу использовать только свою линию CS, оставаясь при этом на общей шине.

На практике это означает, что каждому драйверу нужно передать структуру, которая реализует SpiDevice, а не всю шину целиком.

Как получить SpiDevice?

Мы сказали, что каждому драйверу следует передавать реализацию SpiDevice, а не всю шину SpiBus. Но должны ли мы сами писать эту структуру SpiDevice?

Не обязательно. Можно написать собственную реализацию, но обычно в этом нет необходимости — и её легко сделать неправильно, особенно когда нескольким устройствам нужно согласовывать доступ к шине.

Для этого существует крейт embedded-hal-bus. Он предоставляет готовые обёртки, которые реализуют трейт SpiDevice. Эти обёртки сами управляют доступом к шине, линией CS и, при необходимости, синхронизацией между устройствами.

экосистема SPI во встраиваемом Rust
  • Если ваш проект использует только одно устройство SPI и не нужно делить шину, можно использовать структуру ExclusiveDevice: она даёт эксклюзивный доступ к шине для одного устройства.

  • Если же в проекте несколько устройств SPI делят одну шину, можно выбрать одну из реализаций совместного доступа, например AtomicDevice или CriticalSectionDevice. Они управляют доступом к шине так, что каждое устройство получает свою очередь и не мешает остальным.

Эти структуры позволяют сосредоточиться на использовании или написании драйверов, не заботясь о низкоуровневой координации и шаблонном коде.

Ресурсы

  • Документация embedded-hal по SPI: подробное описание того, как устроены трейты SPI и как их предполагается использовать на разных платформах.

Периферия SPI ESP32

В ESP32 есть четыре контроллера SPI, но для общего использования доступны только два из них: SPI2 и SPI3. Их обычно называют HSPI и VSPI. Два других контроллера, SPI0 и SPI1, зарезервированы для внутренних нужд.

несколько шин SPI с несколькими устройствами

На ESP32 многие выводы GPIO можно использовать для разных функций. Например, один вывод в одном приложении может быть универсальным цифровым выходом, а в другом — линией тактирования SPI. Это называется мультиплексированием: один физический вывод может быть подключён к разным внутренним сигналам в зависимости от того, как вы его настроили в коде.

У ESP32 есть несколько выводов GPIO, напрямую подключённых к каждому контроллеру SPI. Это:

  • Для HSPI (SPI2): GPIO2, GPIO4 и GPIO12–GPIO15

  • Для VSPI (SPI3): GPIO5, GPIO18, GPIO19 и GPIO21–GPIO23

Эти выводы разведены в чипе так, чтобы эффективно работать с SPI, и их часто используют. Однако ESP32 также позволяет переназначить сигналы SPI на многие другие GPIO.

Ниже приведены назначения выводов SPI по умолчанию для отладочной платы ESP32 DevKit:

Канал SPIMOSI (SDI)MISO (SDO)SCLKCS
VSPIGPIO 23GPIO 19GPIO 18GPIO 5
HSPIGPIO 13GPIO 12GPIO 14GPIO 15

Использование SPI в esp-hal

Чтобы использовать SPI в крейте esp-hal, создаём и настраиваем интерфейс SPI с нужными выводами. Вот пример, который использует шину SPI2.

#![allow(unused)]
fn main() {
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::spi::Mode as SpiMode;

// Инициализируем SPI
let spi_bus = Spi::new(
    peripherals.SPI2,
    SpiConfig::default()
        .with_frequency(Rate::from_mhz(60))
        .with_mode(SpiMode::_0),
)
.unwrap()
//CLK
.with_sck(peripherals.GPIO18)
//DIN
.with_mosi(peripherals.GPIO23);
let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
}

Пример использования драйвера

Теперь, когда интерфейс SPI настроен, используем его для общения с SD-картой с помощью крейта embedded_sdmmc. Мы будем использовать ExclusiveDevice из крейта embedded-hal-bus.

#![allow(unused)]
fn main() {
use embedded_hal_bus::spi::ExclusiveDevice;
use embedded_sdmmc::SdCard;
use esp_hal::delay::Delay;

//  Получаем эксклюзивный доступ к шине SPI (она не общая).
let spi_dev = ExclusiveDevice::new(spi_bus, cs, Delay).unwrap();

// Инициализируем драйвер SD-карты с SPI-устройством
let sdcard = SdCard::new(spi_dev, Delay);
}

SD-карта (SDC/MMC)

Рано или поздно может понадобиться сохранять данные, собранные с датчиков, игровые файлы или другую информацию. Одно из идеальных решений — хранить их на SD-карте. В этом разделе мы научимся использовать модуль SD-карты с ESP32.

MMC

MultiMediaCard (MMC) появилась как один из ранних видов флеш-памяти и предшествовала SD-карте. Её обычно использовали в камерах, цифровых фотоаппаратах и портативных музыкальных плеерах. MMC хранит данные как электрические заряды в ячейках флеш-памяти, в отличие от оптических дисков, которые опираются на данные, закодированные лазером на отражающей поверхности.

SD (Secure Digital) карта

Secure Digital Card (SDC), которую обычно называют SD-картой, — это развитие MMC. SD-карты широко используются как внешняя память в электронных устройствах, например в камерах и смартфонах. Меньший вариант — microSD — обычно применяют в смартфонах, дронах и других устройствах.

SD-карты

Изображение: на основе SD card от Tkgd2007, распространяется по лицензиям GFDL и CC BY-SA 3.0, 2.5, 2.0, 1.0.

SD-карты читают и записывают данные блоками, обычно по 512 байт, благодаря чему работают как блочные устройства. Поэтому SD-карты ведут себя во многом как жёсткие диски.

Аппаратные требования

Мы будем использовать адаптер microSD-карты. Чтобы его найти, поищите «Micro SD Card Reader Module» или «Micro SD Card Adapter».

адаптер модуля microSD-карты

Конечно, понадобится и сама microSD-карта. Её нужно отформатировать в FAT32. В зависимости от аппаратного обеспечения вашего компьютера может понадобиться отдельный адаптер SD-карт (не тот, что указан выше), чтобы отформатировать microSD-карту. Некоторые ноутбуки имеют прямую поддержку microSD-карт.

Протокол

Для работы с SD-картой можно использовать протокол SD Bus, протокол SPI или шину UHS-II. Raspberry Pi (но не Raspberry Pi Pico) использует протокол SD Bus, который сложнее, чем SPI. Полные подробности протокола SD Bus публично недоступны и предоставляются только через SD Association. В этом руководстве мы будем использовать протокол SPI, потому что драйвер на Rust, который мы применяем, рассчитан именно на него.

Ссылки:

  • Настоятельно рекомендую посмотреть доклад Джонатана Паланта на Euro Rust 2024 о написании драйвера SD-карты на Rust. Он написал драйвер, который мы будем использовать (изначально он создавал его, чтобы запускать MS-DOS на ARM). Драйвер не предназначен для промышленной эксплуатации.
  • Если хотите понять, как всё работает внутри в режиме SPI, см. эту статью: How to Use MMC/SDC

Схема подключения

Распиновка microSD-карты в режиме SPI

Мы будем рассматривать только microSD-карту, так как именно её мы используем. У microSD восемь выводов, но для режима SPI нужны только шесть. Возможно, вы заметили, что у модуля считывателя SD-карт тоже шесть выводов, на которых указаны функции SPI. В таблице ниже показаны выводы microSD-карты и соответствующие им функции SPI.

схема выводов microSD-карты
Вывод microSD-карты Функция SPI
1 -
2 Выбор микросхемы (CS); также называется Card Select
3 Входные данные (DI), соответствуют MOSI. Для приёма данных от микроконтроллера.
4 VDD — питание (3,3 В)
5 Тактирование (SCK)
6 Земля (GND)
7 Выходные данные (DO), соответствуют MISO. Для передачи данных от microSD-карты к микроконтроллеру.
8 -

Подключение ESP32 к считывателю SD-карт

microSD-карта работает от 3,3 В, поэтому питание её напряжением 5 В может повредить карту. Однако модуль считывателя имеет встроенный стабилизатор напряжения и преобразователь логических уровней, поэтому его безопасно подключать к источнику питания 5 В ESP32.

Вывод ESP32 Провод Вывод SD-карты
GPIO 5
CS
GPIO 18
SCK
GPIO 23
MOSI
GPIO 19
MISO
Vin
VCC
GND
GND

подключение модуля SD-карты к ESP32 DevKit

Пишем код на Rust для чтения файла с SD-карты через ESP32

Создадим простую программу, которая читает файл с SD-карты и выводит его содержимое в системную консоль. Убедитесь, что SD-карта отформатирована в FAT32 и содержит файл для чтения (например, FERRIS.TXT с содержимым «Hello, World!»).

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 sdcard-read

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features»
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Дополнительные крейты

Добавьте в Cargo.toml эти крейты к уже существующим зависимостям.

# драйвер SD-карты
embedded-sdmmc = "0.9.0"
# для преобразования шины SPI в SpiDevice
embedded-hal-bus = "0.3.0"

embedded-sdmmc

Мы будем использовать этот крейт, чтобы читать и записывать файлы на SD-карте для встраиваемых устройств. Он не использует alloc и коллекции, чтобы минимизировать потребление памяти.

embedded-hal-bus

Чтобы понять, зачем нужен крейт embedded-hal-bus, сначала разберёмся с Embedded HAL (Hardware Abstraction Layer). Embedded HAL предоставляет несколько трейтов, которые задают стандартный способ управления распространённой периферией микроконтроллеров, например GPIO, PWM и интерфейсами связи (I2C, SPI, UART). Это позволяет драйверам работать на разных микроконтроллерах (например, ESP32, Raspberry Pi Pico).

Когда мы хотим обмениваться данными с SD-картой по SPI, embedded-hal предоставляет трейты SpiBus и SpiDevice для поддержки совместного использования шины. SpiBus представляет всю шину, а SpiDevice — устройство на этой шине. Специфичные для микроконтроллера HAL (например, esp-hal) обычно реализуют трейт SpiBus, а драйверы устройств, такие как sdmmc, реализуют трейт SpiDevice.

Поэтому нам нужно получить SpiDevice из SpiBus, чтобы использовать его с SD-картой. Здесь помогает крейт embedded-hal-bus. Он предоставляет разные реализации SpiDevice, например CriticalSectionDevice, ExclusiveDevice и другие. Мы используем ExclusiveDevice, поскольку это самый простой способ получить SpiDevice из SpiBus, и он подходит, когда шину SPI не делят другие устройства.

Фиктивный источник времени

Когда вы работаете с файлами на компьютере, вы могли заметить, что у файлов и каталогов есть время создания и изменения, которые фиксируют, когда были сделаны изменения. С SD-картами то же самое: при создании или изменении файлов обновляется их отметка времени.

Драйверу sdmmc нужен источник времени, чтобы получать текущее время для этих обновлений. Он предоставляет трейт TimeSource, который нужно реализовать и передать в VolumeManager при инициализации.

Так как мы только читаем файлы и эту функцию не используем, создадим DummyTimesource, который реализует трейт TimeSource.

#![allow(unused)]
fn main() {
/// Код из https://github.com/rp-rs/rp-hal-boards/blob/main/boards/rp-pico/examples/pico_spi_sd_card.rs
/// Фиктивный источник времени, который важен главным образом при создании файлов.
#[derive(Default)]
pub struct DummyTimesource();

impl TimeSource for DummyTimesource {
    // Теоретически здесь можно использовать RTC rp2040, если бы было
    // внешнее устройство синхронизации времени.
    fn get_timestamp(&self) -> Timestamp {
        Timestamp {
            year_since_1970: 0,
            zero_indexed_month: 0,
            zero_indexed_day: 0,
            hours: 0,
            minutes: 0,
            seconds: 0,
        }
    }
}
}

Настройка SPI для считывателя SD-карт

Чтобы обмениваться данными со считывателем SD-карт, инициализируем экземпляр SPI на периферии SPI2. SD-карты требуют, чтобы тактовая частота SPI работала в диапазоне от 100 кГц до 400 кГц. В этой настройке мы зададим тактовую частоту SPI 400 кГц и назначим нужные выводы на GPIO для правильной связи.

Линия SCK (Serial Clock) будет назначена на GPIO18, MOSI (Master Out, Slave In) — на GPIO23, а MISO (Master In, Slave Out) — на GPIO19. Кроме того, настроим линию CS (Chip Select) на GPIO5 и зададим её начальное состояние High.

#![allow(unused)]
fn main() {
let spi_bus = Spi::new(
    peripherals.SPI2,
    spi::master::Config::default()
        .with_frequency(Rate::from_khz(400))
        .with_mode(spi::Mode::_0),
)
.unwrap()
.with_sck(peripherals.GPIO18)
.with_mosi(peripherals.GPIO23)
.with_miso(peripherals.GPIO19)
.into_async();

let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());
}

Когда SPI настроен, создадим SpiDevice для считывателя SD-карт. Для этого используем ExclusiveDevice из крейта embedded-hal-bus. Наконец, инициализируем SD-карту.

#![allow(unused)]
fn main() {
let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, Delay).unwrap();
let sdcard = SdCard::new(spi_dev, Delay);
}

Вывод размера SD-карты

Далее с помощью менеджера томов получим размер SD-карты в байтах и выведем его.

#![allow(unused)]
fn main() {
println!("Init SD card controller and retrieve card size...");
let sd_size = sdcard.num_bytes().unwrap();
println!("card size is {} bytes\r\n", sd_size);
}

Менеджер томов

Следующий шаг — инициализировать менеджер томов, который будет управлять разделами и файловыми системами на SD-карте.

#![allow(unused)]
fn main() {
// Теперь найдём тома (они же разделы) на нашем блочном устройстве.
// Для этого нужен менеджер томов. Он возьмёт на себя владение блочным устройством.
let volume_mgr = VolumeManager::new(sdcard, DummyTimesource::default());
}

Открываем каталог

Через менеджер томов открываем первый основной раздел (VolumeIdx(0)) на SD-карте. После этого получаем доступ к корневому каталогу этого раздела.

#![allow(unused)]
fn main() {
// Пробуем получить доступ к тому 0 (то есть к первому разделу).
// Объект тома хранит информацию о файловой системе на этом томе.
let volume0 = volume_mgr.open_volume(VolumeIdx(0)).unwrap();

let root_dir = volume0.open_root_dir().unwrap();
}

Открываем файл

Откроем файл FERRIS.TXT в режиме только для чтения. Убедитесь, что вы заранее добавили этот файл на SD-карту с каким-нибудь содержимым.

#![allow(unused)]
fn main() {
let mut my_file = root_dir
    .open_file_in_dir("FERRIS.TXT", embedded_sdmmc::Mode::ReadOnly)
    .unwrap();
}

Вывод содержимого файла

Будем читать файл до конца. Каждый байт преобразуем в символ и выведем его.

#![allow(unused)]
fn main() {
while !my_file.is_eof() {
    let mut buffer = [0u8; 32];

    if let Ok(n) = my_file.read(&mut buffer) {
        for b in &buffer[..n] {
            print!("{}", *b as char);
        }
    }
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку sdcard-read.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/sdcard-read

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Delay, Duration, Timer};
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi;
use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup;
use esp_hal::{clock::CpuClock, spi::master::Spi};
use esp_println::{self as _, print, println};

// Считыватель SD-карт
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

/// Код из https://github.com/rp-rs/rp-hal-boards/blob/main/boards/rp-pico/examples/pico_spi_sd_card.rs
/// Фиктивный источник времени, который важен главным образом при создании файлов.
#[derive(Default)]
pub struct DummyTimesource();

impl TimeSource for DummyTimesource {
    // Теоретически здесь можно использовать RTC rp2040, если бы было
    // внешнее устройство синхронизации времени.
    fn get_timestamp(&self) -> Timestamp {
        Timestamp {
            year_since_1970: 0,
            zero_indexed_month: 0,
            zero_indexed_day: 0,
            hours: 0,
            minutes: 0,
            seconds: 0,
        }
    }
}

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_khz(400))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, Delay).unwrap();
    let sdcard = SdCard::new(spi_dev, Delay);

    println!("Init SD card controller and retrieve card size...");
    let sd_size = sdcard.num_bytes().unwrap();
    println!("card size is {} bytes\r\n", sd_size);

    // Теперь найдём тома (они же разделы) на нашем блочном устройстве.
    // Для этого нужен менеджер томов. Он возьмёт на себя владение блочным устройством.
    let volume_mgr = VolumeManager::new(sdcard, DummyTimesource::default());

    // Пробуем получить доступ к тому 0 (то есть к первому разделу).
    // Объект тома хранит информацию о файловой системе на этом томе.
    let volume0 = volume_mgr.open_volume(VolumeIdx(0)).unwrap();

    let root_dir = volume0.open_root_dir().unwrap();

    let my_file = root_dir
        .open_file_in_dir("FERRIS.TXT", embedded_sdmmc::Mode::ReadOnly)
        .unwrap();

    while !my_file.is_eof() {
        let mut buffer = [0u8; 32];

        if let Ok(n) = my_file.read(&mut buffer) {
            for b in &buffer[..n] {
                print!("{}", *b as char);
            }
        }
    }

    loop {
        Timer::after(Duration::from_secs(30)).await;
    }
}

Код на Rust для записи файла на SD-карту с помощью ESP32

В этом упражнении мы создадим (или перезапишем) файл на SD-карте и запишем в него «Hello, Ferris!».

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 sdcard-write

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features»
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Дополнительные крейты

Добавьте в Cargo.toml эти крейты к уже существующим зависимостям.

# драйвер SD-карты
embedded-sdmmc = "0.9.0"

# для преобразования шины SPI в SpiDevice
embedded-hal-bus = "0.3.0"

## Для разбора времени
jiff = { version = "0.2.16", default-features = false, features = ["static"] }

Источник времени с RTC

Прежде чем переходить к записи, создадим TimeSource для крейта sdmmc. В предыдущем упражнении мы только читали файл и использовали фиктивный источник времени. Однако здесь нам нужно создать файл и убедиться, что его метаданные времени обновляются правильно. Для этого создадим источник времени, достаточно близкий к реальному для наших задач.

Для этого используем встроенный RTC. Это не идеальное решение: RTC требует задать начальное время (мы передадим его через переменную окружения), и он сбрасывается при каждом перезапуске ESP32. Альтернативно можно получать текущее время с NTP-серверов по Wi-Fi.

Создадим структуру SdTimeSource, которая реализует трейт TimeSource и использует Rtc для получения текущего времени. Нужно указать, сколько лет прошло с 1970 года (эпохи Unix). Это значение получим, вычтя 1970 из текущего года. Также нужно указать месяц и день в формате с нумерацией с нуля, а время — как есть.

#![allow(unused)]
fn main() {
static TZ: jiff::tz::TimeZone = jiff::tz::get!("America/New_York");

struct SdTimeSource {
    timer: Rtc<'static>,
}

impl SdTimeSource {
    fn new(timer: Rtc<'static>) -> Self {
        Self { timer }
    }

    fn current_time(&self) -> u64 {
        self.timer.current_time_us()
    }
}

static TZ: jiff::tz::TimeZone = jiff::tz::get!("America/New_York");

impl TimeSource for SdTimeSource {
    fn get_timestamp(&self) -> Timestamp {
        let now_us = self.current_time();

        // Переводим в тип jiff Time
        let now = jiff::Timestamp::from_microsecond(now_us as i64).unwrap();
        let now = now.to_zoned(TZ.clone());

        Timestamp {
            year_since_1970: (now.year() - 1970).unsigned_abs() as u8,
            zero_indexed_month: now.month().wrapping_sub(1) as u8,
            zero_indexed_day: now.day().wrapping_sub(1) as u8,
            hours: now.hour() as u8,
            minutes: now.minute() as u8,
            seconds: now.second() as u8,
        }
    }
}
}

Далее обновим код инициализации SD-карты, чтобы использовать SdTimeSource в качестве источника времени. Текущее время мы передадим через переменную окружения, разберём его с помощью jiff и установим как текущее время для RTC.

#![allow(unused)]
fn main() {
// Таймер для SD-карты
let rtc = Rtc::new(peripherals.LPWR);
let current_time_us: u64 = env!("CURRENT_TIME_US")
    .parse()
    .expect("Invalid microseconds");
rtc.set_current_time_us(current_time_us);

let sd_timer = SdTimeSource::new(rtc);

println!("Init SD card controller and retrieve card size...");
let sd_size = sdcard.num_bytes().unwrap();
println!("card size is {} bytes\r\n", sd_size);

// Теперь найдём тома (они же разделы) на нашем блочном устройстве.
// Для этого нужен менеджер томов. Он возьмёт на себя владение блочным устройством.
let volume_mgr = VolumeManager::new(sdcard, sd_timer);

// Пробуем получить доступ к тому 0 (то есть к первому разделу).
// Объект тома хранит информацию о файловой системе на этом томе.
let volume0 = volume_mgr.open_volume(VolumeIdx(0)).unwrap();

let root_dir = volume0.open_root_dir().unwrap();
}

Запись в файл

Откроем файл в режиме ReadWriteCreateOrTruncate. Если файла нет, он будет создан. Если он существует, его содержимое будет обрезано, то есть очищено.

#![allow(unused)]
fn main() {
let mut my_file = root_dir
.open_file_in_dir(
    "FERRIS.TXT",
    embedded_sdmmc::Mode::ReadWriteCreateOrTruncate,
)
.unwrap();
}

Когда файл открыт, можно записать в него сообщение и затем вызвать flush, чтобы гарантировать сохранение данных.

#![allow(unused)]
fn main() {
let line = "Hello, Ferris!";
if let Ok(()) = my_file.write(line.as_bytes()) {
    my_file.flush().unwrap();
    println!("Written Data");
} else {
    println!("Not wrote");
}
}

Чтобы проверить результат, можно либо подключить SD-карту к компьютеру, либо снова запустить программу чтения SD-карты, которую мы использовали раньше.

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку sdcard-write.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/sdcard-write

Как запустить?

Текущую дату и время мы передадим как переменную окружения. Для этого добавим перед cargo run команду оболочки, которая получит текущие дату и время и передаст их cargo run. Это может не работать в оболочках вроде Fish или в Windows.

CURRENT_DATETIME="$(date '+%Y-%m-%d %H:%M:%S')" cargo run --release

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Delay, Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println::{self as _, println};

// SPI
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi::{self, master::Spi};
use esp_hal::time::Rate;

// Считыватель SD-карт
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};

// Для времени
use esp_hal::rtc_cntl::Rtc;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

struct SdTimeSource {
    timer: Rtc<'static>,
}

impl SdTimeSource {
    fn new(timer: Rtc<'static>) -> Self {
        Self { timer }
    }

    fn current_time(&self) -> u64 {
        self.timer.current_time_us()
    }
}

static TZ: jiff::tz::TimeZone = jiff::tz::get!("America/New_York");

impl TimeSource for SdTimeSource {
    fn get_timestamp(&self) -> Timestamp {
        let now_us = self.current_time();

        // Переводим в тип jiff Time
        let now = jiff::Timestamp::from_microsecond(now_us as i64).unwrap();
        let now = now.to_zoned(TZ.clone());

        Timestamp {
            year_since_1970: (now.year() - 1970).unsigned_abs() as u8,
            zero_indexed_month: now.month().wrapping_sub(1) as u8,
            zero_indexed_day: now.day().wrapping_sub(1) as u8,
            hours: now.hour() as u8,
            minutes: now.minute() as u8,
            seconds: now.second() as u8,
        }
    }
}

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_khz(400))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, Delay).unwrap();

    // Таймер для SD-карты
    let rtc = Rtc::new(peripherals.LPWR);
    let current_time_us: u64 = env!("CURRENT_TIME_US")
        .parse()
        .expect("Invalid microseconds");
    rtc.set_current_time_us(current_time_us);

    let sd_timer = SdTimeSource::new(rtc);

    let sdcard = SdCard::new(spi_dev, Delay);

    println!("Init SD card controller and retrieve card size...");
    let sd_size = sdcard.num_bytes().unwrap();
    println!("card size is {} bytes\r\n", sd_size);

    // Теперь найдём тома (они же разделы) на нашем блочном устройстве.
    // Для этого нужен менеджер томов. Он возьмёт на себя владение блочным устройством.
    let volume_mgr = VolumeManager::new(sdcard, sd_timer);

    // Пробуем получить доступ к тому 0 (то есть к первому разделу).
    // Объект тома хранит информацию о файловой системе на этом томе.
    let volume0 = volume_mgr.open_volume(VolumeIdx(0)).unwrap();

    let root_dir = volume0.open_root_dir().unwrap();

    let my_file = root_dir
        .open_file_in_dir(
            "FERRIS.TXT",
            embedded_sdmmc::Mode::ReadWriteCreateOrTruncate,
        )
        .unwrap();

    let line = "Hello, Ferris!";
    if let Ok(()) = my_file.write(line.as_bytes()) {
        my_file.flush().unwrap();
        println!("Written Data");
    } else {
        println!("Not wrote");
    }

    loop {
        Timer::after(Duration::from_secs(30)).await;
    }
}

RFID

В этом разделе мы будем использовать модуль считывателя RFID-карт (RC522), чтобы читать данные с RFID-меток и брелоков.

Вы наверняка сталкивались с этим в повседневной жизни: открываете дверь квартиры умным ключом, входите в офис по метке, заезжаете на умную парковку или платите бесконтактной банковской картой. Если вы пользовались карточкой-ключом в отеле или транспондером на платной дороге, то уже видели RFID в действии!

RFID-карта и метка
Фото: Security Instrument Corp

RFID (Radio Frequency Identification, радиочастотная идентификация) — это технология, которая использует радиоволны для идентификации и отслеживания предметов и животных. Она беспроводно передаёт сохранённые данные с метки (содержащей микросхему и антенну) считывателю, когда метка находится в зоне действия.

Классификация по диапазону частот

RFID-системы можно классифицировать по рабочей частоте. Основных типов три:

  • Низкая частота (LF, Low Frequency): работает на частоте ~125 кГц с небольшой дальностью (до 10 см). Скорость ниже, обычно используется в системах контроля доступа и для учёта скота.

  • Высокая частота (HF, High Frequency): работает на частоте 13,56 МГц с дальностью от 10 см до 1 м. Скорость средняя, широко используется в системах контроля доступа, например в офисах, квартирах и гостиничных карточках-ключах, а также в билетных системах, платежах и передаче данных. Модуль RC522, который мы будем использовать, работает на частоте 13,56 МГц.

  • Сверхвысокая частота (UHF, Ultra-High Frequency): работает на частоте 860–960 МГц с дальностью до 12 м. Скорость выше, обычно используется в розничном учёте товаров, защите от подделок и логистике.

Классификация по источнику питания

RFID-метки могут быть активными или пассивными — в зависимости от того, как они питаются.

  • Активные метки: у них есть собственная батарея, и они могут передавать сигнал самостоятельно. Обычно их применяют на крупных объектах, например на железнодорожных вагонах, больших многоразовых контейнерах и активах, которые нужно отслеживать на больших расстояниях.
  • Пассивные метки: в отличие от активных, у пассивных меток нет батареи. Они используют электромагнитное поле, которое излучает RFID-считыватель, чтобы включиться. Получив питание, они передают данные по радиоволнам. Это самый распространённый тип RFID-меток, и, скорее всего, именно такие вы встречали в повседневной жизни. Если вы догадались, то да: RC522 работает с пассивными метками.

Компоненты:

RFID-система состоит из считывателя RFID, который технически называется PCD (Proximity Coupling Device, устройство связи по близости). У пассивных RFID-меток питание от считывателя поступает через электромагнитное поле. Сами метки называются RFID-метками или, технически, PICC (Proximity Integrated Circuit Cards, интегральные карты близости). Полезно знать и эти технические термины: они пригодятся, если понадобится смотреть даташиты и другие документы.

Считыватели обычно содержат блоки памяти, например FIFO-буферы и EEPROM. Они также включают криптографические функции для защищённого обмена с метками, позволяя взаимодействовать с ними только аутентифицированным считывателям. Например, RFID-считыватели NXP Semiconductors используют шифр Crypto-1 для аутентификации.

У каждой RFID-метки есть зашитый UID (уникальный идентификатор) размером 4, 7 или 10 байт.

Ссылки

Знакомство с модулем

Мы будем использовать модуль считывателя RFID-карт RC522, построенный на микросхеме MFRC522 (разработки NXP), который работает на частоте 13,56 МГц. Этот модуль широко доступен и недорог, обычно в комплекте идут RFID-метка (MIFARE Classic 1K) и брелок, в каждом из которых по 1 КБ памяти. Даташит MFRC522 можно найти здесь.

модуль RC522

Микроконтроллер может общаться со считывателем по SPI, UART или I2C. Также у модуля есть вывод IRQ (Interrupt Request, запрос прерывания), который может вызывать прерывания, чтобы микроконтроллер (ESP32) узнавал, что метка рядом, вместо того чтобы постоянно опрашивать считыватель (как в детской игре «Мы уже приехали?»). К сожалению, библиотека, которую мы будем использовать, пока не поддерживает эту функцию, поэтому сейчас мы её не используем. Этот раздел обновим, когда поддержка появится. Итак, мы уже приехали?

Дополнительная информация о модуле:

  • Поддерживаемые стандарты: ISO/IEC 14443 A / MIFARE
  • Дальность считывания карт: 0–50 мм
  • Ток в режиме ожидания: 10–13 мА
  • Рабочий ток: 13–26 мА
  • Рабочее напряжение: 3,3 В постоянного тока (⚠️ не используйте 5 В и выше, это приведёт к поломке).

MIFARE

MIFARE — это серия микросхем (IC), используемых в бесконтактных смарт-картах и карточках доступа. Её разработала компания NXP Semiconductors. Карты MIFARE соответствуют стандарту ISO/IEC 14443A и используют методы шифрования, например алгоритм Crypto-1. Самое распространённое семейство — MIFARE Classic, у него есть подтип MIFARE Classic EV1.

Структура памяти

Карта MIFARE Classic 1K разделена на 16 секторов, и каждый сектор содержит 4 блока. Каждый блок может хранить до 16 байт, поэтому общий объём памяти составляет 1 КБ.

16 секторов × 4 блока/сектор × 16 байт/блок = 1024 байта = 1 КБ

Структура памяти MIFARE

Трейлер сектора

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

Note

Карта MIFARE Classic 1K по умолчанию настроена на ключ FF FF FF FF FF FF для KeyA и KeyB. При чтении блока трейлера значение KeyA возвращается как нулевое (00 00 00 00 00 00), а KeyB возвращается как есть.

По умолчанию байты управления доступом (6, 7 и 8 трейлера) установлены в FF 07 80h. Подробнее см. 10-ю страницу даташита. А 9-й байт можно использовать для хранения данных.

Номер байта
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Описание KEY A Биты доступа Пользовательские данные KEY B
Значения по умолчанию FF FF FF FF FF FF FF 07 80 69 FF FF FF FF FF FF

Блок производителя

Первый блок (блок 0) первого сектора (сектор 0) содержит данные производителя микросхемы, включая UID. Этот блок защищён от записи.

Блок данных

У каждого сектора есть блок трейлера, поэтому в каждом секторе для хранения данных можно использовать только 3 блока. Однако в первом секторе доступно только 2 блока, потому что первый блок хранит данные производителя.

Чтобы читать или записывать данные, сначала нужно пройти аутентификацию с помощью KeyA или KeyB этого сектора.

Блоки данных можно разделить на две категории по битам доступа (о них расскажем позже):

  • блок чтения/записи: обычные блоки данных, которые позволяют выполнять базовые операции чтения и записи;
  • блок значения (value block): такие блоки подходят для приложений вроде электронных кошельков, где их обычно используют для хранения числовых значений, например баланса счёта. Поэтому можно выполнять инкремент (например, добавить 10 к балансу) или декремент (например, списать 5 за покупку).

Ссылки

Последовательность работы

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

Чтобы проверить, есть ли поблизости метка, мы отправляем команду REQA в цикле. Если метка рядом, она отвечает ATQA (ответ на запрос).

Получив ответ, мы выбираем карту, и она отправляет свой UID (полные технические подробности этого процесса мы разбирать не будем). После этого аутентифицируем сектор, из которого хотим читать или в который хотим писать. Когда операция завершена, отправляем команду HLTA, чтобы перевести карту в состояние HALT.

Примечание: когда карта находится в состоянии HALT, её может разбудить только команда WUPA, и тогда можно выполнять дальнейшие операции.

Схема обмена с картой MIFARE

Подключение RC522 к ESP32

Распиновка RC522

В RFID-модуле RC522 восемь выводов. распиновка RC522

Вывод Функция SPI Функция I²C Функция UART Описание
3.3V Питание Питание Питание Питание (3,3 В).
GND Земля Земля Земля Подключение к земле.
RST Сброс Сброс Сброс Сброс модуля.
IRQ Прерывание (необязательно) Прерывание (необязательно) Прерывание (необязательно) Запрос прерывания (IRQ) сообщает микроконтроллеру, когда обнаружена RFID-метка. Без использования IRQ микроконтроллеру пришлось бы постоянно опрашивать модуль.
MISO Master-In-Slave-Out SCL TX В режиме SPI работает как Master-In-Slave-Out (MISO). В режиме I²C выполняет функцию линии тактирования (SCL). В режиме UART работает как вывод передачи (TX).
MOSI Master-Out-Slave-In - - В режиме SPI работает как Master-Out-Slave-In (MOSI).
SCK Serial Clock - - В режиме SPI работает как линия тактирования, которая синхронизирует передачу данных.
SDA CS (или SS) SDA RX В режиме SPI работает как выбор микросхемы (CS, также называется Slave Select). В режиме I²C служит линией данных (SDA). В режиме UART работает как вывод приёма (RX).

Подключение RFID-считывателя к ESP32

Для обмена данными между ESP32 и RFID-считывателем мы будем использовать протокол SPI (Serial Peripheral Interface, последовательный периферийный интерфейс). Интерфейс SPI может работать на скорости до 10 Мбит/с. Выводы RST и IRQ пока использовать не будем.

Вывод ESP32 Провод Вывод RFID-считывателя
3.3V
3.3V
GND
GND
GPIO 5
Помечен как SDA, при использовании SPI работает как вывод CS.
GPIO 18
SCK
GPIO 19
MISO
GPIO 23
MOSI
подключение RC522 к ESP32

Чтение UID

Ну а теперь к интересной части! Начнём с простой программы, которая читает UID RFID-метки.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 rfid-uid

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features».
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Дополнительные крейты

Добавьте в Cargo.toml эти крейты к уже существующим зависимостям.

#![allow(unused)]
fn main() {
mfrc522 = "0.8.0"
embedded-hal-bus = "0.2.0"
}

Драйвер mfrc522

Мы будем использовать замечательный крейт «mfrc522». Он всё ещё в разработке, но для наших целей у него есть всё необходимое.

embedded-hal-bus

Чтобы понять, зачем нужен крейт embedded-hal-bus, сначала разберёмся с Embedded HAL (Hardware Abstraction Layer). Embedded HAL предоставляет несколько трейтов, которые задают стандартный способ управления распространённой периферией микроконтроллеров, например GPIO, PWM и интерфейсами связи (такими как I2C, SPI и UART). Это позволяет драйверам работать на разных микроконтроллерах (например, ESP32, Raspberry Pi Pico).

Когда мы хотим обмениваться данными с RFID-меткой по SPI, embedded-hal предоставляет трейты SpiBus и SpiDevice для поддержки совместного использования шины. SpiBus представляет всю шину, а SpiDevice — устройство на этой шине. Специфичные для микроконтроллера HAL (например, esp-hal) обычно реализуют трейт SpiBus, а драйверы устройств, такие как mfrc522, реализуют трейт SpiDevice.

Поэтому нам нужно получить SpiDevice из SpiBus, чтобы работать с RFID-считывателем. Здесь помогает крейт embedded-hal-bus. Он предоставляет разные реализации SpiDevice, например CriticalSectionDevice, ExclusiveDevice и другие. Мы воспользуемся ExclusiveDevice, поскольку это самый простой способ получить SpiDevice из SpiBus, и он подходит, когда другие устройства не используют эту шину SPI.

Настройка SPI для RFID-считывателя

Чтобы обмениваться данными с RFID-модулем, инициализируем экземпляр SPI на периферии SPI2. Здесь мы настроим тактовую частоту SPI на 5 МГц и назначим нужные выводы на GPIO для правильной связи.

#![allow(unused)]
fn main() {
let spi_bus = Spi::new(
    peripherals.SPI2,
    spi::master::Config::default()
        .with_frequency(Rate::from_mhz(5))
        .with_mode(spi::Mode::_0),
)
.unwrap()
.with_sck(peripherals.GPIO18)
.with_mosi(peripherals.GPIO23)
.with_miso(peripherals.GPIO19)
.into_async();

let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());
}

Получение SpiDevice из SPI-шины

Чтобы работать с крейтом mfrc522, нам нужен SpiDevice. Поскольку у нас есть только шина SPI из esp-hal, мы используем крейт embedded_hal_bus, чтобы получить SpiDevice из шины SPI.

#![allow(unused)]
fn main() {
let delay = Delay::new();
let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, delay).unwrap();
}

Инициализация mfrc522

Далее инициализируем драйвер MFRC522. Для этого оборачиваем экземпляр SpiDevice в обёртку SpiInterface из крейта mfrc522 и передаём её при инициализации Mfrc522:

#![allow(unused)]
fn main() {
let spi_interface = SpiInterface::new(spi_dev);
let mut rfid = Mfrc522::new(spi_interface).init().unwrap();
}

Вспомогательная функция для вывода массива байтов в виде hex-строки

Эту вспомогательную функцию мы используем, чтобы превратить массив байтов u8 (например, UID) в печатаемую hex-строку. Она будет использоваться во всех упражнениях с RFID для вывода данных. В зависимости от требований конкретного упражнения её может понадобиться немного изменить.

#![allow(unused)]
fn main() {
fn print_hex_bytes(data: &[u8]) {
    for &b in data.iter() {
        print!("{:02x} ", b);
    }
    println!("");
}
}

Чтение UID и вывод

Основная логика чтения UID простая. Мы непрерывно отправляем команду REQA (Request A), чтобы проверить, есть ли поблизости карта или метка. Если карта присутствует, она отвечает ATQA (Answer To reQuest code A).

ATQA содержит информацию о типе метки, её возможностях и других деталях. Используя ответ ATQA, мы выбираем метку и получаем её UID.

#![allow(unused)]
fn main() {
loop {
    if let Ok(atqa) = rfid.reqa() {
        println!("Answer To reQuest code A");
        Timer::after(Duration::from_millis(50)).await;
        if let Ok(uid) = rfid.select(&atqa) {
            print_hex_bytes(uid.as_bytes());
            Timer::after(Duration::from_millis(500)).await;
        }
    }
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку rfid-uid.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/rfid-uid

Вывод UID

После прошивки кода в ESP32 поднесите RFID-метку к считывателю. Байты UID будут выведены в системную консоль в hex-формате. Затем попробуйте то же самое с брелоком: он должен вывести другой UID.

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// RFID-считыватель
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use mfrc522::Mfrc522;
use mfrc522::comm::blocking::spi::SpiInterface;

use esp_println::{self as _, print, println};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(5))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());

    let delay = Delay::new();
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, delay).unwrap();

    let spi_interface = SpiInterface::new(spi_dev);
    let mut rfid = Mfrc522::new(spi_interface).init().unwrap();

    loop {
        if let Ok(atqa) = rfid.reqa() {
            println!("Answer To reQuest code A");
            Timer::after(Duration::from_millis(50)).await;
            if let Ok(uid) = rfid.select(&atqa) {
                print_hex_bytes(uid.as_bytes());
                Timer::after(Duration::from_millis(500)).await;
            }
        }
    }
}

fn print_hex_bytes(data: &[u8]) {
    for &b in data.iter() {
        print!("{:02x} ", b);
    }
    println!("");
}

Чтение данных

В этом разделе мы прочитаем все блоки первого сектора (сектора 0). Как мы говорили ранее, чтобы читать или записывать конкретный блок на RFID-метке, сначала нужно пройти аутентификацию для соответствующего сектора.

Создание проекта

Выполните те же шаги, что и раньше: добавьте зависимости и выполните базовую настройку для RFID-компонента. На этот раз мы изменим код, чтобы читать данные.

Аутентификация

Большинство меток поставляются с ключом по умолчанию, обычно 0xFF, повторённым шесть раз. Возможно, стоит посмотреть документацию, чтобы найти ключ по умолчанию, или попробовать другие распространённые ключи. Для нашего RFID-считывателя ключ по умолчанию — 0xFF, повторённый шесть раз.

Для аутентификации нам нужны:

  • UID метки (получаем с помощью команд REQA и Select).
  • Номер блока внутри сектора.
  • Ключ (в данном случае зашит в коде).

Чтение блока

Прежде чем читать данные из блока, нужно сначала пройти аутентификацию для блока. Если операция чтения успешна, функция возвращает 16 байт данных из блока. Первый сектор (сектор 0) состоит из 4 блоков с абсолютными номерами от 0 до 3. Для следующих секторов абсолютные номера блоков увеличиваются соответственно (например, для сектора 1 это блоки 4, 5, 6, 7).

#![allow(unused)]
fn main() {
fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();
        print_hex_bytes(&data);
    }
}
}

В этой функции мы зашиваем ключ аутентификации в код в виде массива байтов со значениями 0xFF. Затем вычисляем смещение блока, умножая номер выбранного сектора на 4. Например, если выбран сектор 2, умножение на 4 даёт смещение блока 8. Затем передаём UID, смещение блока и ключ аутентификации, чтобы авторизовать доступ к сектору.

Когда аутентификация прошла успешно, мы проходим по 4 блокам внутри сектора, начиная со смещения блока. Для каждого блока читаем данные и выводим их содержимое в виде hex-строки.

Главный цикл

В главном цикле, после того как RFID-метка обнаружена (получен ATQA) и выбрана для дальнейшего обмена, мы вызываем функцию read_sector. Здесь указываем сектор 0 для чтения. Когда данные блоков прочитаны, отправляем команды HLTA и stop_crypto1, чтобы перевести карту в состояние HALT.

#![allow(unused)]
fn main() {
let sector_num = 0;
loop {
    if let Ok(atqa) = rfid.reqa() {
        println!("Got atqa");
        Timer::after(Duration::from_millis(50)).await;
        if let Ok(uid) = rfid.select(&atqa) {
            println!("Reading sector: {}", sector_num);
            read_sector(&uid, sector_num, &mut rfid);
            rfid.hlta().unwrap();
            rfid.stop_crypto1().unwrap();
        }
    }
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку rfid-read.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/rfid-read

Вывод первого сектора

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

вывод первого сектора RFID-метки

Там, где видно «13 73 73 31», будет выведен UID вашей RFID-метки.

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// RFID-считыватель
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use mfrc522::Mfrc522;
use mfrc522::comm::blocking::spi::SpiInterface;

use esp_println::{self as _, print, println};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(5))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());

    let delay = Delay::new();
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, delay).unwrap();

    let spi_interface = SpiInterface::new(spi_dev);
    let mut rfid = Mfrc522::new(spi_interface).init().unwrap();

    let sector_num = 0;
    loop {
        if let Ok(atqa) = rfid.reqa() {
            println!("Got atqa");
            Timer::after(Duration::from_millis(50)).await;
            if let Ok(uid) = rfid.select(&atqa) {
                println!("Reading sector: {}", sector_num);
                read_sector(&uid, sector_num, &mut rfid);
                rfid.hlta().unwrap();
                rfid.stop_crypto1().unwrap();
            }
        }
    }
}

fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();
        print_hex_bytes(&data);
    }
}

fn print_hex_bytes(data: &[u8]) {
    for &b in data.iter() {
        print!("{:02x} ", b);
    }
    println!("");
}

Дамп всей памяти

Вы уже научились читать данные из каждого блока первого сектора (сектора 0), пройдя аутентификацию. Теперь мы переберём все секторы. Повторная аутентификация требуется каждый раз при переходе к новому сектору. Для каждого сектора мы выведем 16 байт данных из каждого из 4 блоков.

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

Можно либо работать над тем же проектом, который мы создали в предыдущей главе, либо создать новый, выполнить те же шаги и затем следовать этим инструкциям.

Перебор секторов

Мы создадим отдельную функцию, которая перебирает все 16 секторов (с 0 по 15), читает все блоки внутри каждого сектора и выводит их данные.

#![allow(unused)]
fn main() {
fn dump_memory<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    for sector in 0..16 {
        // Вывод номера сектора
        println!("\n\n-----------SECTOR {}-----------", sector);
        read_sector(uid, sector, rfid);
    }
}
}

Подписи

Функция read_sector работает по той же логике, что и раньше, но с добавленным форматированием и подписями. Теперь она выводит абсолютный номер блока, номер блока относительно сектора, а также подписи для блока производителя (MFD) и блоков трейлера сектора.

#![allow(unused)]
fn main() {
fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let rel_block = abs_block - block_offset;
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();

        // Вывод абсолютного и относительного номеров блока
        print!("\nBLOCK {} (REL: {}) | ", abs_block, rel_block);
        print_hex_bytes(&data);

        // Вывод типа блока
        let block_type = get_block_type(sector, rel_block);
        print!("| {} ", block_type);
    }
}
}

Для определения типа блока по номеру сектора и относительному номеру блока мы создадим небольшую вспомогательную функцию.

#![allow(unused)]
fn main() {
const fn get_block_type(sector: u8, rel_block: u8) -> &'static str {
    match rel_block {
        0 if sector == 0 => "MFD",
        3 => "TRAILER",
        _ => "DATA",
    }
}
}

Главный цикл

В главном цикле почти ничего не меняется. Мы просто вызываем функцию dump_memory вместо read_sector.

#![allow(unused)]
fn main() {
loop {
    if let Ok(atqa) = rfid.reqa() {
        println!("Got atqa");
        Timer::after(Duration::from_millis(50)).await;
        if let Ok(uid) = rfid.select(&atqa) {
            dump_memory(&uid, &mut rfid);
            rfid.hlta().unwrap();
            rfid.stop_crypto1().unwrap();
        }
    }
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку rfid-dump.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/rfid-dump

Дамп

Когда вы запустите программу и поднесёте метку или брелок к считывателю, вы увидите вывод примерно такого вида. Если вы заметили байты 0x40..0x43 в блоке 18 (блок 2 сектора 4) и задались вопросом, откуда они, — отличное замечание! Это пользовательские данные, которые я записал на метку.

вывод дампа памяти RFID

На изображении показаны только первые 5 секторов. Когда вы запустите программу, вы увидите данные всех 16 секторов.

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// RFID-считыватель
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use mfrc522::Mfrc522;
use mfrc522::comm::blocking::spi::SpiInterface;

use esp_println::{self as _, print, println};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(5))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());

    let delay = Delay::new();
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, delay).unwrap();

    let spi_interface = SpiInterface::new(spi_dev);
    let mut rfid = Mfrc522::new(spi_interface).init().unwrap();

    loop {
        if let Ok(atqa) = rfid.reqa() {
            println!("Got atqa");
            Timer::after(Duration::from_millis(50)).await;
            if let Ok(uid) = rfid.select(&atqa) {
                dump_memory(&uid, &mut rfid);
                rfid.hlta().unwrap();
                rfid.stop_crypto1().unwrap();
            }
        }
    }
}

fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let rel_block = abs_block - block_offset;
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();

        // Вывод абсолютного и относительного номеров блока
        print!("\nBLOCK {} (REL: {}) | ", abs_block, rel_block);
        print_hex_bytes(&data);

        // Вывод типа блока
        let block_type = get_block_type(sector, rel_block);
        print!("| {} ", block_type);
    }
}

fn print_hex_bytes(data: &[u8]) {
    for &b in data.iter() {
        print!("{:02x} ", b);
    }
}

const fn get_block_type(sector: u8, rel_block: u8) -> &'static str {
    match rel_block {
        0 if sector == 0 => "MFD",
        3 => "TRAILER",
        _ => "DATA",
    }
}

fn dump_memory<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    for sector in 0..16 {
        // Вывод номера сектора
        println!("\n\n-----------SECTOR {}-----------", sector);

        read_sector(uid, sector, rfid);
    }
}

Контроль доступа

На метке есть биты доступа, которые задают правила доступа к данным, хранящимся на ней. В этой главе мы разберём, как работают эти биты. Раздел может показаться немного сложным, поэтому я постараюсь объяснить его как можно проще и понятнее.

Caution

Будьте осторожны при записи битов доступа: неверные значения могут сделать сектор недоступным.

Права

Это основные права, которыми задаются условия доступа. В таблице описано каждое действие и указано, к каким блокам оно применимо: обычным блокам данных (чтение/запись), блокам значений или трейлерам секторов.

ОперацияОписаниеПрименимо к типу блока
Read (чтение)Читает один блок памятиЧтение/запись, значение, трейлер сектора
Write (запись)Записывает один блок памятиЧтение/запись, значение, трейлер сектора
Increment (инкремент)Увеличивает содержимое блока и сохраняет результат во внутреннем буфере передачиЗначение
Decrement (декремент)Уменьшает содержимое блока и сохраняет результат во внутреннем буфере передачиЗначение
Restore (восстановление)Читает содержимое блока во внутренний буфер передачиЗначение
Transfer (передача)Записывает содержимое внутреннего буфера передачи в блокЗначение, чтение/запись

Условия доступа

Начнём с главного: с условий доступа. Во время исследования я заметил, что многим людям трудно разобраться в разделе об условиях доступа в даташите. Вот моя попытка объяснить его понятно 🤞.

Для управления правами каждого блока используется всего три бита, то есть одна из восьми комбинаций. В официальном даташите это обозначается как CXY (C1₀, C1₂… C3₃) для битов доступа. Первое число (X) в этой записи — номер бита доступа, от 1 до 3, каждый из которых соответствует определённому типу прав. Однако смысл этих прав меняется в зависимости от того, является ли блок блоком данных или трейлером. Второе число (Y) в подстрочном индексе обозначает относительный номер блока — от 0 до 3.

Таблица 1: условия доступа для трейлера сектора

В оригинальном даташите подстрочный индекс в таблице не указан. Я добавил индекс «3», так как трейлер сектора находится в блоке 3.

Important

Если ключ можно прочитать, его нельзя использовать как ключ аутентификации. Поэтому в этой таблице всякий раз, когда ключ B можно прочитать, он не может служить ключом аутентификации. Если вы заметили, да: ключ A прочитать нельзя никогда.

Биты доступа Условие доступа для Примечание
Ключ A Биты доступа Ключ B
C13 C23 C33 Чтение Запись Чтение Запись Чтение Запись
0 0 0 никогда ключ A ключ A никогда ключ A ключ A Ключ B можно прочитать
0 1 0 никогда никогда ключ A никогда ключ A никогда Ключ B можно прочитать
1 0 0 никогда ключ B ключ A|B никогда никогда ключ B
1 1 0 никогда никогда ключ A|B никогда никогда никогда
0 0 1 никогда ключ A ключ A ключ A ключ A ключ A Ключ B можно прочитать; конфигурация по умолчанию
0 1 1 никогда ключ B ключ A|B ключ B никогда ключ B
1 0 1 никогда никогда ключ A|B ключ B никогда никогда
1 1 1 никогда никогда ключ A|B никогда никогда никогда

Как разобраться в этой таблице?

Это простая таблица, которая показывает связь между битовыми комбинациями и правами.

Например: Допустим, вы выбрали «1 0 0» (3-я строка таблицы). Тогда вы не можете прочитать ни ключ A, ни ключ B. Однако вы можете изменить значение ключа A, а также ключа B, используя ключ B. Биты доступа можно прочитать любым из ключей, A или B. Но изменить биты доступа нельзя никогда.

Теперь вопрос: где хранить эти биты? Мы разместим их в 6-м, 7-м и 8-м байтах в определённом месте, которое будет объяснено чуть позже.

Таблица 2: условия доступа для блоков данных

Это относится ко всем блокам данных. В оригинальном даташите подстрочный индекс «Y» не указан, я добавил его для наглядности. Здесь «Y» означает номер блока (от 0 до 2).

Конфигурация по умолчанию означает, что и ключ A, и ключ B могут выполнять все операции. Однако, как видно из предыдущей таблицы, ключ B можно прочитать (в конфигурации по умолчанию), поэтому его нельзя использовать для аутентификации. Значит, использовать можно только ключ A.

Биты доступа Условие доступа для Применение
C1Y C2Y C3Y Чтение Запись Инкремент Декремент, передача/восстановление
0 0 0 ключ A|B ключ A|B ключ A|B ключ A|B Конфигурация по умолчанию
0 1 0 ключ A|B никогда никогда никогда блок чтения/записи
1 0 0 ключ A|B ключ B никогда никогда блок чтения/записи
1 1 0 ключ A|B ключ B ключ B ключ A|B блок значения
0 0 1 ключ A|B никогда никогда ключ A|B блок значения
0 1 1 ключ B ключ B никогда никогда блок чтения/записи
1 0 1 ключ B никогда никогда никогда блок чтения/записи
1 1 1 никогда никогда никогда никогда блок чтения/записи
Примечание: «Если ключ B можно прочитать в трейлере сектора, его нельзя использовать для аутентификации. В результате, если считыватель использует ключ B для аутентификации блока с условиями доступа, которые используют ключ B, карта откажет в любом дальнейшем доступе к памяти после аутентификации».

Как разобраться в этой таблице?

Она похожа на предыдущую: показывает связь между битовыми комбинациями и правами.

Например: Если выбрать «0 1 0» (2-я строка таблицы) и применить это право к блоку 1, то читать блок 1 можно будет либо ключом A, либо ключом B. Однако никакие другие операции с блоком 1 выполнять нельзя.

Обозначение такое: номер блока записывается как подстрочный индекс к названиям битов (например, C11, C21, C31). Здесь подстрочный индекс «1» означает блок 1. Для выбранной комбинации «0 1 0» это значит:

  • C11 = 0
  • C21 = 1
  • C31 = 0

Эти биты тоже будут размещены в 6-м, 7-м и 8-м байтах в определённом месте, которое будет объяснено чуть позже.

Таблица 3: таблица условий доступа

Раскрасим исходную таблицу, чтобы лучше видеть, что означает каждый бит. 7-й и 3-й биты каждого байта относятся к трейлеру сектора. 6-й и 2-й биты соответствуют блоку 2. 5-й и 1-й биты связаны с блоком 1. 4-й и 0-й биты относятся к блоку 0.

Черта над обозначением означает инвертированное значение. То есть если значение CXy равно 0, то CXy становится 1.

Байт 7 6 5 4 3 2 1 0
Байт 6 C23 C22 C21 C20 C13 C12 C11 C10
Байт 7 C13 C12 C11 C10 C33 C32 C31 C30
Байт 8 C33 C32 C31 C30 C23 C22 C21 C20

По умолчанию биты доступа равны «FF 07 80». Попробуем понять, что это значит.

Байт 7 6 5 4 3 2 1 0
Байт 6 1 1 1 1 1 1 1 1
Байт 7 0 0 0 0 0 1 1 1
Байт 8 1 0 0 0 0 0 0 0

Из таблицы выше можно вывести значения CXY. Обратите внимание, что только C33 равен 1, а все остальные значения равны 0. Теперь посмотрите таблицы 1 и 2, чтобы понять, какому праву это соответствует.

Блок C1Y C2Y C3Y Доступ
Блок 0 0 0 0 Все права с ключом A
Блок 1 0 0 0 Все права с ключом A
Блок 2 0 0 0 Все права с ключом A
Блок 3 (трейлер) 0 0 1 Ключ A можно записать с помощью ключа A. Биты доступа и ключ B можно читать и записывать только с помощью ключа A.

Поскольку ключ B можно прочитать, использовать его для аутентификации нельзя.

Калькулятор на следующей странице

Всё ещё запутались? Используйте калькулятор на следующей странице, чтобы поэкспериментировать с разными комбинациями. Меняйте права для каждого блока и смотрите, как соответственно меняются значения битов доступа.

Ссылки

Калькулятор битов доступа MIFARE Classic 1K

Декодирование: Вы можете изменить «Биты доступа», и таблицы блоков данных и трейлера сектора обновятся автоматически.

Кодирование: Нажмите кнопку «Изменить» в каждой строке таблицы, чтобы выбрать нужные условия доступа. Это обновит биты доступа.

Caution

Запись неверного значения в биты условий доступа может сделать сектор недоступным.

Биты доступа

Условия доступа для блоков данных:

Блок C1Y C2Y C3Y Чтение Запись Инкремент Декремент/Передача/Восстановление Примечания Действие
Блок 0 0 0 0 ключ A|B ключ A|B ключ A|B ключ A|B Конфигурация по умолчанию
Блок 1 0 0 0 ключ A|B ключ A|B ключ A|B ключ A|B Конфигурация по умолчанию
Блок 2 0 0 0 ключ A|B ключ A|B ключ A|B ключ A|B Конфигурация по умолчанию
C1Y C2Y C3Y Чтение Запись Инкремент Декремент/Передача/Восстановление Примечания
0 0 0 ключ A|B ключ A|B ключ A|B ключ A|B Конфигурация по умолчанию
0 1 0 ключ A|B никогда никогда никогда блок чтения/записи
1 0 0 ключ A|B ключ B никогда никогда блок чтения/записи
1 1 0 ключ A|B ключ B ключ B ключ A|B блок значения
0 0 1 ключ A|B никогда никогда ключ A|B блок значения
0 1 1 ключ B ключ B никогда никогда блок чтения/записи
1 0 1 ключ B никогда никогда никогда блок чтения/записи
1 1 1 никогда никогда никогда никогда блок чтения/записи

Условия доступа для трейлера сектора (блок 3):

C13 C23 C33 Чтение ключа A Запись ключа A Чтение битов доступа Запись битов доступа Чтение ключа B Запись ключа B Примечания Действие
0 0 1 никогда ключ A ключ A ключ A ключ A ключ A Ключ B можно прочитать; Конфигурация по умолчанию
C13 C23 C33 Чтение ключа A Запись ключа A Чтение битов доступа Запись битов доступа Чтение ключа B Запись ключа B Примечания
0 0 0 никогда ключ A ключ A никогда ключ A ключ A Ключ B можно прочитать
0 1 0 никогда никогда ключ A никогда ключ A никогда Ключ B можно прочитать
1 0 0 никогда ключ B ключ A|B никогда никогда ключ B
1 1 0 никогда никогда ключ A|B никогда никогда никогда
0 0 1 никогда ключ A ключ A ключ A ключ A ключ A Ключ B можно прочитать; Конфигурация по умолчанию
0 1 1 никогда ключ B ключ A|B ключ B никогда ключ B
1 0 1 никогда никогда ключ A|B ключ B никогда никогда
1 1 1 никогда никогда ключ A|B никогда никогда никогда

Ссылки

Запись данных

Мы запишем данные в блок 2 сектора 4. Сначала выведем содержимое блока до записи, а затем снова — после. Для записи используем функцию mf_write из крейта mfrc522.

Caution

Если случайно записать данные не в тот блок и перезаписать блок трейлера, можно изменить ключ аутентификации или биты доступа, и сектор станет недоступным.

Создание проекта

Выполните те же шаги, что и раньше: добавьте зависимости и завершите базовую настройку для RFID-компонента. На этот раз мы изменим код, чтобы записывать данные.

Функция записи

Как обычно, мы зашиваем ключ аутентификации в код, вычисляем смещение блока по номеру сектора и затем определяем абсолютный номер блока, в который хотим записать данные. Проходим аутентификацию для блока и записываем данные.

#![allow(unused)]
fn main() {
fn write_block<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rel_block: u8,
    data: [u8; 16],
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    let abs_block = block_offset + rel_block;

    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    rfid.mf_write(abs_block, data)
        .map_err(|_| "Write failed")
        .unwrap();
}
}

Главный цикл

Мы запишем данные в 3-й блок 4-го сектора (это соответствует абсолютному блоку 18, вычисляемому как 4×4 = 16 + 2 = 18). В этот блок будут записаны данные «implRust». Поскольку блок имеет 16 байт, оставшиеся неиспользуемые байты дополняем нулевыми байтами (0x00).

Перед обновлением содержимого блока и после него выводим содержимое в цикле.

#![allow(unused)]
fn main() {
let target_sector = 4;
let rel_block = 2; // относительный блок внутри сектора (3-й блок в секторе)
const DATA: [u8; 16] = [
    b'i', b'm', b'p', b'l', b'R', b'u', b's', b't', // "implRust"
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Остальные байты — 0x00
];

loop {
    if let Ok(atqa) = rfid.reqa() {
        println!("Got atqa");
        Timer::after(Duration::from_millis(50)).await;
        if let Ok(uid) = rfid.select(&atqa) {
            println!("\r\n----Before Write----");
            read_sector(&uid, target_sector, &mut rfid);

            write_block(&uid, target_sector, rel_block, DATA, &mut rfid);

            println!("\r\n----After Write----");
            read_sector(&uid, target_sector, &mut rfid);
            rfid.hlta().unwrap();
            rfid.stop_crypto1().unwrap();
        }
    }
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку rfid-write.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/rfid-write

Вывод

Когда вы запустите программу, в выводе будет показано шестнадцатеричное представление «implRust» в третьей строке. вывод записи RFID

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// RFID-считыватель
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use mfrc522::Mfrc522;
use mfrc522::comm::blocking::spi::SpiInterface;

use esp_println::{self as _, print, println};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(5))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());

    let delay = Delay::new();
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, delay).unwrap();

    let spi_interface = SpiInterface::new(spi_dev);
    let mut rfid = Mfrc522::new(spi_interface).init().unwrap();

    let target_sector = 4;
    let rel_block = 2; // относительный блок внутри сектора
    const DATA: [u8; 16] = [
        b'i', b'm', b'p', b'l', b'R', b'u', b's', b't', // "implRust"
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Остальные байты — 0x00
    ];

    loop {
        if let Ok(atqa) = rfid.reqa() {
            println!("Got atqa");
            Timer::after(Duration::from_millis(50)).await;
            if let Ok(uid) = rfid.select(&atqa) {
                println!("\r\n----Before Write----");
                read_sector(&uid, target_sector, &mut rfid);

                write_block(&uid, target_sector, rel_block, DATA, &mut rfid);

                println!("\r\n----After Write----");
                read_sector(&uid, target_sector, &mut rfid);
                rfid.hlta().unwrap();
                rfid.stop_crypto1().unwrap();
            }
        }
    }
}

fn write_block<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rel_block: u8,
    data: [u8; 16],
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    let abs_block = block_offset + rel_block;

    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    rfid.mf_write(abs_block, data)
        .map_err(|_| "Write failed")
        .unwrap();
}

fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) {
    const AUTH_KEY: [u8; 6] = [0xFF; 6];

    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();
        print_hex_bytes(&data);
    }
}

fn print_hex_bytes(data: &[u8]) {
    for &b in data.iter() {
        print!("{:02x} ", b);
    }
    println!("");
}

Смена ключа аутентификации

Давайте изменим ключ аутентификации (ключ A) для сектора 1. По умолчанию он равен FF FF FF FF FF FF. Мы поменяем его на 52 75 73 74 65 64, что в hex означает «Rusted». Для этого нужно изменить блок трейлера (блок 3) сектора 1, не трогая остальную часть сектора.

Прежде чем продолжить, полезно проверить текущее содержимое этого блока. Запустите программу Дамп памяти или Чтение данных, чтобы проверить.

Note

Карта MIFARE Classic 1K по умолчанию настроена на ключ FF FF FF FF FF FF для KeyA и KeyB. При чтении блока трейлера значение KeyA возвращается как нулевое (00 00 00 00 00 00), а KeyB возвращается как есть.

Мы также изменим содержимое ключа B, чтобы проверить, что запись прошла успешно. Установим ключ B в hex-байты «Ferris» (46 65 72 72 69 73).

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

План такой:

  1. В программе мы зашиваем ключ по умолчанию (FF FF FF FF FF FF) в переменную current_key.
  2. Задаём new_key как Rusted (в hex-байтах). Это нужно, чтобы вывести содержимое блока после записи; иначе мы получим ошибку аутентификации.
  3. Программа выведет содержимое блока до и после записи.

Как только ключ обновлён, поднесите метку к считывателю снова. Скорее всего, вы увидите ошибку «Auth failed». Если вы уже догадались почему — поздравляю! Новый ключ успешно записан, поэтому зашитый в код current_key больше не работает. Чтобы проверить, измените программу read-data так, чтобы она использовала новый ключ (Rusted), и попробуйте снова.

Ключ и данные

Массив DATA содержит новый ключ A («Rusted» в hex), биты доступа и ключ B («Ferris» в hex). current_key задан значением по умолчанию FF FF FF FF FF FF, а new_key — первыми 6 байтами DATA, то есть «Rusted».

#![allow(unused)]
fn main() {
let target_sector = 1;
let rel_block = 3; // относительный блок внутри сектора (4-й блок сектора 1)
const DATA: [u8; 16] = [
    0x52, 0x75, 0x73, 0x74, 0x65, 0x64, // Ключ A: "Rusted"
    0xFF, 0x07, 0x80, 0x69, // Биты доступа и байт трейлера
    0x46, 0x65, 0x72, 0x72, 0x69, 0x73, // Ключ B: "Ferris"
];
let current_key = &[0xFF; 6];
let new_key: &[u8; 6] = &DATA[..6].try_into().unwrap(); // Первые 6 байт блока

}

Функция write_block

Мы немного изменили функцию write_block, чтобы она принимала ключ как аргумент.

#![allow(unused)]
fn main() {
fn write_block<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rel_block: u8,
    data: [u8; 16],
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
    auth_key: &[u8; 6], // дополнительный аргумент: ключ аутентификации
) {
    let block_offset = sector * 4;
    let abs_block = block_offset + rel_block;

    rfid.mf_authenticate(uid, block_offset, auth_key)
        .map_err(|_| "Auth failed")
        .unwrap();

    rfid.mf_write(abs_block, data)
        .map_err(|_| "Write failed")
        .unwrap();
}
}

Функция read_sector

Для функции read_sector мы сделали аналогичное изменение.

#![allow(unused)]
fn main() {
fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
    auth_key: &[u8; 6], // дополнительный аргумент: ключ аутентификации
) {
    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, auth_key)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();
        print_hex_bytes(&data);
    }
}
}

Главный цикл

В главном цикле ничего нового нет. Все функции чтения и записи вам уже знакомы. Мы просто выводим содержимое сектора до и после смены ключа.

#![allow(unused)]
fn main() {
loop {
    if let Ok(atqa) = rfid.reqa() {
        println!("Got atqa");
        Timer::after(Duration::from_millis(50)).await;
        if let Ok(uid) = rfid.select(&atqa) {
            println!("\r\n----Before Write----");
            read_sector(&uid, target_sector, &mut rfid, current_key);

            write_block(&uid, target_sector, rel_block, DATA, &mut rfid, current_key);

            println!("\r\n----After Write----");
            read_sector(&uid, target_sector, &mut rfid, new_key);
            rfid.hlta().unwrap();
            rfid.stop_crypto1().unwrap();
        }
    }
}
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку rfid-change-key.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/rfid-change-key

Вывод

Как видно в выводе, при запуске программа покажет содержимое целевого блока до и после записи. После смены ключа, когда метка снова поднесётся к считывателю, появится сообщение «auth failed», потому что current_key изменился. Новый ключ — 52 75 73 74 65 64 (Rusted).

вывод смены ключа аутентификации

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

Примечание: если интересно, почему ключ A не выводится: как мы объясняли ранее, ключ A нельзя прочитать, если он используется для аутентификации. Подробнее см. главу Биты доступа.

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::gpio::{Level, Output, OutputConfig};
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// RFID-считыватель
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use mfrc522::Mfrc522;
use mfrc522::comm::blocking::spi::SpiInterface;

use esp_println::{self as _, print, println};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(5))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    .with_sck(peripherals.GPIO18)
    .with_mosi(peripherals.GPIO23)
    .with_miso(peripherals.GPIO19)
    .into_async();

    let sd_cs = Output::new(peripherals.GPIO5, Level::High, OutputConfig::default());

    let delay = Delay::new();
    let spi_dev = ExclusiveDevice::new(spi_bus, sd_cs, delay).unwrap();

    let spi_interface = SpiInterface::new(spi_dev);
    let mut rfid = Mfrc522::new(spi_interface).init().unwrap();

    let target_sector = 1;
    let rel_block = 3; // относительный блок внутри сектора (4-й блок сектора 1)
    const DATA: [u8; 16] = [
        0x52, 0x75, 0x73, 0x74, 0x65, 0x64, // Ключ A: "Rusted"
        0xFF, 0x07, 0x80, 0x69, // Биты доступа и байт трейлера
        0x46, 0x65, 0x72, 0x72, 0x69, 0x73, // Ключ B: "Ferris"
    ];
    let current_key = &[0xFF; 6];
    // при желании вернуть всё к 0xFF
    // const DATA: [u8; 16] = [
    //     0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // Ключ A
    //     0xFF, 0x07, 0x80, 0x69, // Биты доступа и байт трейлера
    //     0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // Ключ B
    // ];
    // let current_key = &[0x52, 0x75, 0x73, 0x74, 0x65, 0x64];
    let new_key: &[u8; 6] = &DATA[..6].try_into().unwrap(); // Первые 6 байт блока

    loop {
        if let Ok(atqa) = rfid.reqa() {
            println!("Got atqa");
            Timer::after(Duration::from_millis(50)).await;
            if let Ok(uid) = rfid.select(&atqa) {
                println!("\r\n----Before Write----");
                read_sector(&uid, target_sector, &mut rfid, current_key);

                write_block(&uid, target_sector, rel_block, DATA, &mut rfid, current_key);

                println!("\r\n----After Write----");
                read_sector(&uid, target_sector, &mut rfid, new_key);
                rfid.hlta().unwrap();
                rfid.stop_crypto1().unwrap();
            }
        }
    }
}

fn write_block<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rel_block: u8,
    data: [u8; 16],
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
    auth_key: &[u8; 6], // дополнительный аргумент: ключ аутентификации
) {
    let block_offset = sector * 4;
    let abs_block = block_offset + rel_block;

    rfid.mf_authenticate(uid, block_offset, auth_key)
        .map_err(|_| "Auth failed")
        .unwrap();

    rfid.mf_write(abs_block, data)
        .map_err(|_| "Write failed")
        .unwrap();
}

fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>>(
    uid: &mfrc522::Uid,
    sector: u8,
    rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
    auth_key: &[u8; 6], // дополнительный аргумент: ключ аутентификации
) {
    let block_offset = sector * 4;
    rfid.mf_authenticate(uid, block_offset, auth_key)
        .map_err(|_| "Auth failed")
        .unwrap();

    for abs_block in block_offset..block_offset + 4 {
        let data = rfid.mf_read(abs_block).map_err(|_| "Read failed").unwrap();
        print_hex_bytes(&data);
    }
}

fn print_hex_bytes(data: &[u8]) {
    for &b in data.iter() {
        print!("{:02x} ", b);
    }
    println!("");
}

Идеи проектов

1. Контроль доступа с RFID и OLED

Постройте систему контроля доступа, которая выводит «Доступ разрешён» или «Доступ запрещён» на OLED-дисплей при сканировании RFID-меток. По желанию добавьте пищалку для звуковой индикации статуса доступа.

Компоненты

  • MFRC522 (модуль RFID-считывателя) и RFID-метки/карты
  • OLED-дисплей 0,96 дюйма (интерфейс I2C)
  • [необязательно] Пищалка (для звуковой индикации)
  • Блок питания (например, адаптер 5 В или батарея)

Полезные материалы для изучения

2. Автоматические ворота гаража с RFID

Я нашёл интересное видео, в котором показана система автоматических ворот гаража с RFID на Arduino. Вы можете повторить этот проект, используя ESP32 и Rust. Смотреть видео.

Компоненты

  • MFRC522 (модуль RFID-считывателя) и RFID-метки/карты
  • Сервопривод
  • [необязательно] Пищалка (для звуковой индикации)
  • Блок питания (например, адаптер 5 В или батарея)

Полезные материалы для изучения

3. Простой умный дверной замок на ESP32 и RFID

Модель двери можно построить из картонной коробки: сервопривод открывает дверь, когда предъявлен правильный ключ. Если используется неправильный ключ, дверь остаётся закрытой, а по желанию пищалка издаёт сигнал. Вы можете повторить этот проект, используя ESP32 и Rust. Смотреть видео.

Компоненты

  • MFRC522 (модуль RFID-считывателя) и RFID-метки/карты
  • Сервопривод
  • [необязательно] Пищалка (для звуковой индикации)
  • Блок питания (например, адаптер 5 В или батарея)

Полезные материалы для изучения

Примеры работ

Если вы создадите проект на основе этой идеи, смело присылайте pull request со ссылкой на свой проект, и мы разместим её здесь!

  • ESP32 RFID Access Control: имитация умного дверного замка на Rust и ESP32 с RFID, необязательным сервоприводом и OLED-дисплеем для имитации и управления доступом через дверь.

Джойстик

В этом разделе мы разберём, как использовать модуль джойстика. Он похож на джойстики контроллеров PS2 (PlayStation 2). Такие джойстики широко применяются в играх, а также для управления дронами, радиоуправляемыми машинами, роботами и другими устройствами, чтобы менять положение или направление.

Знакомство с железом — модуль джойстика

джойстик

Ручку джойстика можно двигать вертикально и горизонтально, и он передаёт своё положение (оси X и Y) микроконтроллеру (например, ESP32). Кроме того, ручку можно нажать вниз, как кнопку. Джойстик обычно работает от 5 В, но его также можно подключить к 3,3 В.

Как это работает?

Модуль джойстика содержит два потенциометра по 10 кОм: один для оси X и другой для оси Y. Также в нём есть кнопка, которая срабатывает при нажатии на ручку.

Когда вы двигаете джойстик справа налево или слева направо (ось X), один из потенциометров перемещается соответственно. Аналогично, когда вы двигаете его вверх и вниз (ось Y), перемещается второй потенциометр.

джойстик с потенциометрами и кнопкой

Вы также можете увидеть, как срабатывает кнопка, когда вы нажимаете на ручку.

Движение джойстика и соответствующие значения АЦП

Когда вы двигаете джойстик по оси X или Y, он выдаёт аналоговый сигнал, напряжение которого меняется от 0 до 3,3 В (или до 5 В, если подключить его к питанию 5 В). Когда джойстик находится в центральном (нейтральном) положении, выходное напряжение примерно равно 1,65 В, то есть половине VCC (в нашем случае VCC равен 3,3 В).

Note

Причина, по которой в центре напряжение 1,65 В, в том, что потенциометр работает как делитель напряжения. Когда потенциометр перемещается, его сопротивление меняется, и делитель выдаёт соответствующее напряжение. См. раздел о делителе напряжения.

У джойстика всего 5 выводов, и мы вскоре разберём, за что отвечает каждый из них. Два из них передают положение по осям X и Y, их нужно подключить к выводам АЦП микроконтроллера.

Как вы, возможно, уже знаете, ESP32 имеет 12-битный АЦП, который преобразует аналоговые сигналы (разности напряжений) в цифровые значения. Поскольку АЦП 12-битный, аналоговые значения будут представлены цифровыми значениями от 0 до 4095. Если вы не знакомы с АЦП, см. раздел об АЦП, который мы рассматривали ранее.

движение джойстика и соответствующие значения АЦП ESP32

Примечание:

Значения АЦП на изображении — лишь приблизительные ориентиры, они не будут точными. Например, я получил около 1850 по X и Y в центральном положении. Когда я двигал ручку к стороне с распиновкой, X падал до 0, а в противоположную сторону — до 4095. То же самое относится и к оси Y. Поэтому вам, возможно, придётся откалибровать свой джойстик.

Раскладка выводов

У джойстика всего 5 выводов: питание, земля, выход оси X, выход оси Y и выход переключателя (кнопки).

джойстик
Вывод джойстика Описание
GND Вывод земли. Должен быть подключён к земле схемы.
VCC Вывод питания (обычно 5 В или 3,3 В).
VRX Аналоговый выход по оси X: его напряжение меняется в зависимости от горизонтального положения джойстика, от 0 В до VCC при движении влево и вправо.
VRY Аналоговый выход по оси Y: его напряжение меняется в зависимости от вертикального положения джойстика, от 0 В до VCC при движении вверх и вниз.
SW Вывод переключателя. Когда ручку джойстика нажимают, этот вывод обычно притягивается к низкому уровню (к GND).

Подключение джойстика к ESP32

Подключим джойстик к ESP32. Нужно соединить выводы VRX и VRY с выводами АЦП ESP32. Джойстик будем питать от 3,3 В вместо 5 В, потому что выводы GPIO ESP32 допускают только 3,3 В. Подключение к 5 В может повредить выводы ESP32. К счастью, джойстик может работать и от 3,3 В.

Вывод ESP32 Провод Вывод джойстика
GND
GND
3.3V
VCC
GPIO 13
VRX
GPIO 14
VRY
GPIO 32
SW
подключение джойстика к esp32

Вывод значений АЦП при движении джойстика

В этой программе мы будем наблюдать в реальном времени, как движение джойстика влияет на значения АЦП. Подключим ESP32 к джойстику.

Когда вы двигаете джойстик, соответствующие значения АЦП выводятся в системную консоль. Их можно сравнить с предыдущей диаграммой «Движение и значения АЦП»: они должны примерно совпадать. Нажатие на ручку джойстика выведет «Button Pressed» вместе с текущими координатами.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 joystick-movement

Откроется экран с вопросом о выборе опций.

  • Выберите опцию «Enable unstable HAL features».
  • Затем выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

Крейт nb упрощает неблокирующий ввод-вывод (например, чтение датчиков или данных UART): он возвращает nb::Result с ошибкой WouldBlock, когда операция ещё не готова, что позволяет повторить её позже без блокировки.

Если интересно, зачем он нужен: функция read_oneshot у АЦП возвращает nb::Result и может вернуть nb::Error::WouldBlock, если результат ещё не готов. Обернув её в nb::block, мы заставляем код повторять попытки, пока АЦП не закончит работу и не вернёт нормальный результат.

nb = "1.1.0"

Настройка АЦП

Настроим АЦП: GPIO 13 и GPIO 14 соответствуют выводам VRX и VRY джойстика.

#![allow(unused)]
fn main() {
let mut adc2_config = AdcConfig::new();
let mut vrx_pin = adc2_config.enable_pin(peripherals.GPIO13, Attenuation::_11dB);
let mut vry_pin = adc2_config.enable_pin(peripherals.GPIO14, Attenuation::_11dB);

let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);

}

Также настроим GPIO 32 как вход с подтягивающим резистором (pull-up) для кнопки:

#![allow(unused)]
fn main() {
let btn = Input::new(
    peripherals.GPIO32,
    InputConfig::default().with_pull(Pull::Up),
);
}

Вывод координат

Мы хотим выводить координаты, только когда значения vrx или vry меняются больше определённого порога. Так мы избегаем постоянного вывода ненужных значений.

Для этого инициализируем переменные, которые хранят предыдущие значения, и флаг, определяющий, когда выводить:

#![allow(unused)]
fn main() {
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut prev_btn_state = false;
let mut print_vals = true;
}

Чтение значений АЦП:

Сначала считываем значения АЦП для vrx и vry. Если при чтении возникает ошибка, мы игнорируем её и продолжаем цикл:

#![allow(unused)]
fn main() {
let Ok(vry): Result<u16, _> = nb::block!(adc2.read_oneshot(&mut vry_pin)) else {
    continue;
};
let Ok(vrx): Result<u16, _> = nb::block!(adc2.read_oneshot(&mut vrx_pin)) else {
    continue;
};
}

Проверка изменения по порогу:

Далее проверяем, превышает ли абсолютная разность между текущим и предыдущим значением vrx или vry порог (например, 100). Если да, обновляем предыдущие значения и устанавливаем флаг print_vals в true:

#![allow(unused)]
fn main() {
if vrx.abs_diff(prev_vrx) > 100 {
    prev_vrx = vrx;
    print_vals = true;
}

if vry.abs_diff(prev_vry) > 100 {
    prev_vry = vry;
    print_vals = true;
}
}

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

Вывод координат

Если print_vals равен true, сбрасываем его в false и выводим координаты X и Y через USB-serial:

#![allow(unused)]
fn main() {
if print_vals {
    print_vals = false;
    println!("X: {} Y: {}\r\n", vrx, vry);
}
}

Определение нажатия кнопки по переходу состояния

Кнопка обычно находится в высоком состоянии. Когда вы нажимаете на кнопку ручки, она переключается из высокого состояния в низкое. Однако поскольку программа работает в цикле, простая проверка на низкий уровень может привести к многократному срабатыванию на одно нажатие. Чтобы этого избежать, мы регистрируем нажатие только один раз, фиксируя переход из высокого состояния в низкое, который означает, что кнопка нажата.

Для этого мы запоминаем предыдущее состояние кнопки и сравниваем его с текущим перед выводом сообщения «Button Pressed». Если кнопка сейчас в низком состоянии (нажата), а раньше была в высоком (не нажата), мы считаем это новым нажатием и выводим сообщение. Затем обновляем предыдущее состояние до текущего, чтобы корректно отслеживать будущие переходы.

#![allow(unused)]
fn main() {
let btn_state = btn.is_low();
if btn_state && !prev_btn_state {
    println!("Button Pressed");
    print_vals = true;
}
prev_btn_state = btn_state;
}

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку joystick-movement.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/joystick-movement/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println::{self as _, println};

use esp_hal::analog::adc::{Adc, AdcConfig, Attenuation};
use esp_hal::gpio::{Input, InputConfig, Pull};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let btn = Input::new(
        peripherals.GPIO32,
        InputConfig::default().with_pull(Pull::Up),
    );

    let mut adc2_config = AdcConfig::new();
    let mut vrx_pin = adc2_config.enable_pin(peripherals.GPIO13, Attenuation::_11dB);
    let mut vry_pin = adc2_config.enable_pin(peripherals.GPIO14, Attenuation::_11dB);

    let mut adc2 = Adc::new(peripherals.ADC2, adc2_config);

    let mut prev_vrx: u16 = 0;
    let mut prev_vry: u16 = 0;
    let mut prev_btn_state = false;
    let mut print_vals = true;

    loop {
        let Ok(vry): Result<u16, _> = nb::block!(adc2.read_oneshot(&mut vry_pin)) else {
            continue;
        };
        let Ok(vrx): Result<u16, _> = nb::block!(adc2.read_oneshot(&mut vrx_pin)) else {
            continue;
        };

        if vrx.abs_diff(prev_vrx) > 100 {
            prev_vrx = vrx;
            print_vals = true;
        }

        if vry.abs_diff(prev_vry) > 100 {
            prev_vry = vry;
            print_vals = true;
        }

        let btn_state = btn.is_low();
        if btn_state && !prev_btn_state {
            println!("Button Pressed");
            print_vals = true;
        }
        prev_btn_state = btn_state;

        if print_vals {
            print_vals = false;

            println!("X: {} Y: {}\r\n", vrx, vry);
        }

        Timer::after(Duration::from_millis(50)).await;
    }
}

Bluetooth

Bluetooth не нуждается в представлении. Вы, вероятно, пользуетесь им каждый день: подключаете беспроводные наушники к телефону, беспроводную мышь, смарт-часы и многое другое. В умных домах Bluetooth связывает разные устройства. Например, с помощью приложений на телефоне с поддержкой Bluetooth можно управлять освещением, бытовой техникой и термостатами.

Note

Bluetooth назван в честь короля Харальда «Блютуса» Гормссона. Во время встречи Джим Кардах из Intel предложил использовать это имя как временное кодовое название. Позже Кардах сказал: «Король Харальд Блютус… был знаменит тем, что объединил Скандинавию, так же как мы хотели объединить индустрии ПК и сотовой связи с помощью беспроводного соединения малого радиуса действия»

Категории

Технология Bluetooth делится на два основных типа: Bluetooth Classic и Bluetooth Low Energy (BLE).

Bluetooth Classic

Bluetooth Classic — это исходная версия Bluetooth, которая обычно используется в устройствах, требующих непрерывной передачи данных, например в беспроводных гарнитурах, колонках и мышах. До появления BLE её называли просто «Bluetooth», но теперь её называют Bluetooth Classic, чтобы отличать от BLE. Она обеспечивает более высокую скорость передачи данных, что делает её идеальной для приложений реального времени, таких как потоковое аудио.

Bluetooth Low Energy (BLE)

BLE разработан для низкого энергопотребления, поэтому отлично подходит для устройств, которые время от времени отправляют небольшие объёмы данных. Это особенно полезно для питаемых от батареи IoT-устройств, таких как фитнес-трекеры и датчики окружающей среды. По сравнению с Classic у BLE меньше задержка, то есть на начало отправки или приёма данных после установки соединения уходит меньше времени.

Dual Mode

Многие современные устройства поддерживают и Bluetooth Classic, и BLE, и эта возможность называется «Dual Mode». Например, смартфон может использовать Classic для потоковой передачи музыки, а BLE — для подключения к смарт-часам.

В общем, Bluetooth Classic лучше подходит для непрерывной передачи данных, например для аудио и видео в реальном времени, а BLE идеален для маломощной связи с медицинскими мониторами, датчиками и другими небольшими устройствами.

А как насчёт ESP32?

ESP32 поддерживает Bluetooth в режиме dual-mode, то есть совместим и с Bluetooth Classic, и с BLE.

BLE

Чтобы работать с Bluetooth Low Energy, нужно понять несколько ключевых концепций. Я постараюсь объяснить просто и расскажу ровно столько, чтобы вы смогли начать, не перегружая вас деталями. Так что держитесь и давайте начнём.

Стек BLE

На изображении ниже показан стек протоколов Bluetooth Low Energy (BLE). Стек BLE — основа связи между BLE-устройствами. Мы не будем подробно разбирать контроллер (нижние уровни), так как для нашей задачи это не нужно. Однако важно понимать ключевые концепции хост-части, такие как GAP и GATT.

Стек протоколов Bluetooth LE

GAP => Как устройства подключаются и обмениваются данными

GAP (Generic Access Profile) определяет, как BLE-устройства объявляют о себе, подключаются и устанавливают связь. Он описывает роли устройств (например, central и peripheral), параметры соединения и режимы безопасности. GAP отвечает за то, как устройства находят друг друга и инициируют обмен данными.

GATT => Как устройства обмениваются данными и структурируют их

GATT (Generic Attribute Profile) определяет, как BLE-устройства обмениваются данными. Он организует данные в иерархию сервисов и характеристик, позволяя клиентам (например, приложению на смартфоне) читать и записывать данные, а также подписываться на обновления от BLE-периферии (например, датчика).

Крейты Rust

Для работы с Bluetooth Low Energy (BLE) есть два основных крейта Rust:

  1. Bleps: легковесный стек BLE-периферии, созданный в первую очередь для тестирования, демонстраций и личных проектов. Он не предназначен для соответствия требованиям сертификации. В официальном репозитории рекомендуется использовать крейт Trouble, если вы собираетесь работать с асинхронным кодом.

  2. Trouble (или TrouBLE): реализация BLE Host на Rust с долгосрочной целью пройти сертификацию. В спецификации BLE хост работает как верхний уровень интерфейса хост-контроллера (HCI), а контроллер образует нижний уровень.

Ссылки

Если хотите глубже разобраться, можете изучить следующие материалы

Generic Access Profile (GAP)

GAP (Generic Access Profile) — это набор правил, которые определяют, как устройства Bluetooth Low Energy (BLE) обнаруживают друг друга, подключаются и обмениваются данными.

Типы связи BLE

BLE поддерживает два основных способа связи: связь с установкой соединения и широковещательную связь.

Связь с установкой соединения: два устройства создают прямое соединение и могут отправлять и получать данные в обе стороны. Например, смарт-часы подключаются к телефону и постоянно передают данные, такие как пульс, уведомления и количество шагов.

Широковещательная связь: устройство отправляет данные всем ближайшим устройствам без установки прямого соединения. Например, Bluetooth-маячок в магазине рассылает рекламные сообщения всем телефонам в зоне действия.

Роли устройств

Представьте эти роли так же, как в реальном общении между людьми. Точно так же, как люди общаются по-разному в зависимости от своей роли в разговоре, у устройств Bluetooth Low Energy (BLE) есть свои роли.

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

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

📱 Central (с установкой соединения): это устройство ищет другие устройства, подключается к ним или читает их рекламные данные. Обычно у него больше вычислительной мощности и ресурсов. Оно может обслуживать несколько соединений одновременно.

Например, смартфон одновременно подключён к смарт-часам, фитнес-трекеру и беспроводным наушникам.

⌚ Peripheral (с установкой соединения): это устройство передаёт рекламные пакеты и принимает запросы на соединение от central-устройств.
Например, фитнес-трекер рекламирует себя, чтобы смартфон мог найти его и подключиться для синхронизации данных о здоровье.

Central и Peripheral

Режимы обнаружения BLE-периферии и флаги рекламы

BLE-периферия может находиться в разных режимах обнаружения, которые влияют на то, как её найдут central-устройства. Эти режимы задаются с помощью флагов рекламы в рекламном пакете.

Режимы обнаружения

  1. Non-Discoverable (не обнаруживается)

    • Режим по умолчанию, когда реклама не активна или когда установлено соединение.
    • Устройство нельзя обнаружить и к нему нельзя подключиться.
  2. Limited-Discoverable (ограниченно обнаруживается)

    • Обнаруживается в течение ограниченного времени, чтобы экономить энергию.
    • Если соединение не установлено, устройство переходит в режим ожидания.
  3. General-Discoverable (обнаруживается постоянно)

    • Реклама идёт бесконечно, пока не будет установлено соединение.

Флаги рекламы

Эти флаги показывают режим обнаружения и уровень поддержки BLE. Их объединяют побитовым ИЛИ (|):

БитФлаг (в крейте TrouBLE)Описание
0AD_FLAG_LE_LIMITED_DISCOVERABLEРежим ограниченного обнаружения (временная реклама).
1LE_GENERAL_DISCOVERABLEРежим постоянного обнаружения (реклама бесконечно).
2BR_EDR_NOT_SUPPORTEDУстанавливается, когда устройство не поддерживает (или не хочет поддерживать) Bluetooth Classic (BR/EDR).
3SIMUL_LE_BR_CONTROLLERУстанавливается, если устройство может одновременно использовать Bluetooth Low Energy (LE) и Bluetooth Classic на уровне контроллера.
4SIMUL_LE_BR_HOSTУстанавливается, если устройство может одновременно работать с Bluetooth Low Energy (LE) и Bluetooth Classic на уровне хоста.
5-7ReservedНе используется.

Пример с крейтом Bleps:

#![allow(unused)]
fn main() {
create_advertising_data(&[
   // Флаги
   AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED)
   // Другие данные рекламы
]).unwrap()
}

Пример с крейтом Trouble:

#![allow(unused)]
fn main() {
    let mut adv_data = [0; 31];
    let len = AdStructure::encode_slice(
        &[
            // Флаги
            AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
            // Другие данные рекламы
        ],
        &mut adv_data[..],
    )
    .unwrap();
}

Этот код настраивает периферийное устройство на бесконечную рекламу (LE_GENERAL_DISCOVERABLE) и одновременно сообщает, что оно не поддерживает Bluetooth Classic (BR_EDR_NOT_SUPPORTED).

Протокол атрибутов (ATT) и Generic Attribute Profile (GATT)

В предыдущей главе мы узнали, что слой GAP помогает устройствам Bluetooth LE находить друг друга с помощью рекламы (advertising). После подключения им нужен способ отправлять и получать данные. Здесь вступают в дело слои ATT и GATT: они определяют, как данные структурируются и передаются между устройствами.

Модель «клиент-сервер»

В GATT есть две роли: сервер и клиент. Сервер хранит данные в виде атрибутов, а клиент обращается к этим данным. Обычно периферийное устройство (например, датчик) выступает сервером, а центральное устройство (например, смартфон) — клиентом.

Note

Central-Peripheral и Server-Client: роли клиента и сервера в GATT независимы от ролей peripheral и central в Generic Access Profile (GAP). Это значит, что центральное устройство может быть как клиентом, так и сервером, и то же самое относится к периферийному устройству.

Например, в сценарии со смартфоном и фитнес-трекером трекер (периферийное устройство) обычно выступает GATT-сервером: он хранит данные датчиков, например пульс или количество шагов, а смартфон (центральное устройство) выступает GATT-клиентом и читает эти данные, чтобы показать их в приложении. Однако если смартфону нужно отправить трекеру настройки (например, яркость экрана или будильник), он на время становится сервером, а фитнес-трекер выступает клиентом, чтобы принять эти настройки.

Протокол атрибутов (ATT) — основа

ATT определяет, как данные хранятся в виде атрибутов; атрибуты — это базовая основа и строительные блоки. У каждого атрибута есть уникальный дескриптор (handle), тип (16-битный идентификатор или 128-битный UUID), права доступа (например, чтение или запись) и данные (само значение). Клиент может читать, записывать данные или подписываться на них.

Generic Attribute Profile (GATT) — организация данных

GATT дополняет ATT структурой и смыслом данных. Он определяет, как данные группируются и к ним обращаются.

GATT организует атрибуты в:

  • Характеристика (Characteristic): отдельный фрагмент данных, которым может делиться устройство. Другие устройства могут читать его, записывать в него или получать обновления. Например, характеристика Heart Rate Measurement хранит текущий пульс и может отправлять обновления при его изменении.

  • Сервис (Service): набор связанных характеристик, сгруппированных вместе. Например, сервис Heart Rate включает характеристики измерения пульса и расположения датчика на теле.

  • Профиль (Profile): набор связанных сервисов (например, Heart Rate Service, Device Information Service).

На следующем рисунке показаны профиль, сервисы и характеристики датчика пульса: Профиль, сервисы и характеристики GATT

UUID

Вернёмся к части UUID в атрибуте. У каждого сервиса и характеристики должен быть уникальный идентификатор. UUID может быть либо стандартным 16-битным UUID, определённым Bluetooth-SIG, либо пользовательским 128-битным UUID.

Список предопределённых UUID можно получить здесь: https://www.bluetooth.com/specifications/assigned-numbers/

Предопределённый UUID для сервиса Heart Rate: UUID сервиса Heart Rate

Предопределённый UUID для характеристики Heart Rate Monitor: UUID характеристики Heart Rate Monitor

Пользовательский UUID: В наших примерах мы будем использовать пользовательские UUID вместо предопределённых. Однако если вы реализуете распространённый сервис, например датчик пульса, лучше использовать предопределённые UUID из официальной документации.

Чтобы сгенерировать пользовательский UUID, можно зайти на UUID Generator и создать уникальные UUID для ваших сервисов и характеристик.

Как написать код на Rust для Bluetooth Low Energy (BLE) на ESP32

Создадим простую программу, которая демонстрирует Bluetooth Low Energy (BLE). В этом упражнении мы будем использовать крейт Trouble вместе с Embassy. Определим GATT-сервис с двумя характеристиками:

  • Одна характеристика поддерживает и чтение, и запись.
  • Вторая характеристика позволяет только чтение.

Я сгенерировал UUID для этих атрибутов (сервисов и характеристик), и вы можете использовать те же самые.

Больше примеров смотрите в репозитории Trouble: https://github.com/embassy-rs/trouble/tree/main/examples

Подключение к Bluetooth ESP32

Для взаимодействия с Bluetooth ESP32 будем использовать приложение nRF Connect for Mobile:

🔗 nRF Connect for Mobile

С помощью этого приложения можно читать и записывать данные, которые предоставляет ESP32.

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

Для этого проекта включим поддержку async (Embassy). Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 bluetooth-low-energy

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features».
  • Выберите опцию «Enable allocations via the esp-alloc crate».
  • Выберите опцию «Adds embassy framework support».
  • Теперь можно включить «Enable BLE via the esp-radio crate (embassy-trouble)».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем зависимости

embassy-futures = "0.1.1"

# Необходимо для работы макроса gatt_server
embassy-sync = { version = "0.7" }

Инициализация контроллера Wi-Fi

ESP32 использует один радиомодуль и для Wi-Fi, и для Bluetooth. Чтобы инициализировать Bluetooth, мы будем использовать тот же контроллер, что и для Wi-Fi.

#![allow(unused)]
fn main() {
let radio_init = esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller");
}

Инициализируем Bluetooth-коннектор.

#![allow(unused)]
fn main() {
// больше примеров: https://github.com/embassy-rs/trouble/tree/main/examples/esp32
let transport = BleConnector::new(&radio_init, peripherals.BT, Default::default()).unwrap();
let ble_controller = ExternalController::<_, 20>::new(transport);

// let mut resources: HostResources<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> =
//     HostResources::new();
// let _stack = trouble_host::new(ble_controller, &mut resources);
}

После создания контроллера BLE HCI вызовем функцию run, которую определим чуть позже, чтобы запустить стек BLE.

#![allow(unused)]
fn main() {
ble::run(ble_controller).await;

loop {
    Timer::after(Duration::from_secs(5)).await;
}
}

Полный код main.rs

Это полный код файла main.rs. Далее создадим модуль ble, в котором реализуем функцию run вместе с вспомогательными функциями для рекламы и обработки событий.

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use bt_hci::controller::ExternalController;
use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;
use esp_radio::ble::controller::BleConnector;

// Наш модуль
use bluetooth_low_energy as lib;
use lib::ble;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern crate alloc;

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(_spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let radio_init = esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller");
    // больше примеров: https://github.com/embassy-rs/trouble/tree/main/examples/esp32
    let transport = BleConnector::new(&radio_init, peripherals.BT, Default::default()).unwrap();
    let ble_controller = ExternalController::<_, 20>::new(transport);
    // let mut resources: HostResources<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> =
    //     HostResources::new();
    // let _stack = trouble_host::new(ble_controller, &mut resources);

    ble::run(ble_controller).await;

    loop {
        Timer::after(Duration::from_secs(5)).await;
    }
}

Модуль BLE

В этом модуле мы определим, какие данные ESP32 отправляет и получает, настроим и запустим сервер и будем обрабатывать подключения. Этот пример взят из официального репозитория Trouble и немного изменён. Больше примеров можно найти в репозитории.

Создадим модуль BLE, обновив файл lib.rs:

#![allow(unused)]
#![no_std]
fn main() {
pub mod ble;
}

Далее будем работать в файле модуля ble.rs. Сначала добавим все необходимые импорты:

#![allow(unused)]
fn main() {
use defmt::{info, warn};

use embassy_futures::join::join;
use embassy_futures::select::select;

use embassy_time::Timer;

// BLE:
use trouble_host::prelude::*;
}

Зададим максимальное количество одновременных соединений и каналов L2CAP для стека BLE:

#![allow(unused)]
fn main() {
/// Максимальное число подключений
const CONNECTIONS_MAX: usize = 1;

/// Максимальное число каналов L2CAP.
const L2CAP_CHANNELS_MAX: usize = 1;
}

GATT-сервис

Мы используем макросы gatt_server и gatt_service, чтобы определить GATT-сервер с сенсорным сервисом, содержащим две характеристики.

#![allow(unused)]
fn main() {
// Определение GATT-сервера
#[gatt_server]
struct Server {
    sensor_service: SensorService,
}

/// Сервис батареи
#[gatt_service(uuid = "a9c81b72-0f7a-4c59-b0a8-425e3bcf0a0e")]
struct SensorService {
    #[characteristic(uuid = "13c0ef83-09bd-4767-97cb-ee46224ae6db", read, notify)]
    sensor_data: u8,

    #[characteristic(uuid = "c79b2ca7-f39d-4060-8168-816fa26737b7", write, read)]
    sensor_settings: bool,
}
}

Структура Server представляет наш GATT-сервер и содержит SensorService. SensorService определяет две характеристики:

  • sensor_data: значение типа u8, которое имитирует данные датчика в этом упражнении. Оно поддерживает операции read и notify, позволяя клиентам читать текущее значение датчика и получать уведомления при его изменении. Позже мы будем обновлять это значение в цикле, чтобы показать обновления в реальном времени. Когда освоите BLE, можно попробовать подключить настоящий датчик и отправлять данные на мобильное устройство.

  • sensor_settings: логическое значение, которое поддерживает и запись, и чтение. Это позволяет клиентам настраивать параметры датчика и читать текущую конфигурацию. Так мы показываем, что значение можно изменить со стороны клиента.

Запуск стека BLE

Здесь мы определяем функцию run, которая настраивает и запускает стек BLE на нашем ESP32.

Общая схема работы

Общая схема работы кода BLE
#![allow(unused)]
fn main() {
/// Запускает BLE-стек.
pub async fn run<C>(controller: C)
where
    C: Controller,
{
    // Фиксированный «случайный» адрес удобен для тестирования. В реальных сценариях можно использовать, например, 6-байтовый MAC-адрес
    // (способ его получения зависит от платформы).
    let address: Address = Address::random([0xff, 0x8f, 0x1a, 0x05, 0xe4, 0xff]);
    info!("Our address = {:?}", address);

    let mut resources: HostResources<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> =
        HostResources::new();
    let stack = trouble_host::new(controller, &mut resources).set_random_address(address);
    let Host {
        mut peripheral,
        runner,
        ..
    } = stack.build();

    info!("Starting advertising and GATT service");
    let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig {
        name: "implRust",
        appearance: &appearance::power_device::GENERIC_POWER_DEVICE,
    }))
    .unwrap();

    let _ = join(ble_task(runner), async {
        loop {
            match advertise("impl Rust", &mut peripheral, &server).await {
                Ok(conn) => {
                    // запускаем задачи при установлении соединения с central, чтобы они не работали, когда никто не подключён.
                    let a = gatt_events_task(&server, &conn);
                    let b = custom_task(&server, &conn, &stack);
                    // работаем, пока не завершится любая из задач (обычно из-за разрыва соединения),
                    // затем возвращаемся в состояние рекламы.
                    select(a, b).await;
                }
                Err(e) => {
                    let e = defmt::Debug2Format(&e);
                    panic!("[adv] error: {:?}", e);
                }
            }
        }
    })
    .await;
}
}

Разберём эту функцию по шагам. Функция принимает один аргумент «controller», который должен реализовывать трейт Controller из крейта Trouble.

Настройка BLE Host

Создадим экземпляр BLE Host. Для этого сначала нужно определить BLE-адрес.

BLE-адрес

У каждого BLE-устройства есть уникальный Bluetooth Device Address — 48-битное число, которое работает как MAC-адрес.

Категории BLE-адресов

Есть два основных типа BLE-адресов:

Публичный адрес: постоянный глобально уникальный код, который производитель присваивает устройству. Он никогда не меняется и регистрируется в IEEE. Каждый адрес может принадлежать только одному устройству, а для получения адреса нужно заплатить.

Случайный адрес: такой адрес используется чаще, потому что его не нужно регистрировать в IEEE и можно настроить самостоятельно.

Случайные адреса можно далее разделить на:

  • Статический: остаётся неизменным до перезапуска устройства
  • Приватный (динамический): меняется со временем, чтобы защищать вашу приватность. Может быть отслеживаемым (с ключом Identity Resolving Key) или полностью анонимным. Случайные адреса помогают защищать приватность, скрывая настоящую личность устройства.
#![allow(unused)]
fn main() {
// Фиксированный «случайный» адрес удобен для тестирования. В реальных сценариях можно использовать, например, 6-байтовый MAC-адрес
// (способ его получения зависит от платформы).
let address: Address = Address::random([0xff, 0x8f, 0x1a, 0x05, 0xe4, 0xff]);
info!("Our address = {:?}", address);
}

Инициализация BLE Host

Инициализируем BLE Host, передав контроллер и конфигурацию ресурсов. Затем задаём случайный адрес, который будет использовать host. Наконец, вызываем метод build у стека, который возвращает BLE-периферию и runner.

#![allow(unused)]
fn main() {

let mut resources: HostResources<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> =
    HostResources::new();
let stack = trouble_host::new(controller, &mut resources).set_random_address(address);
let Host {
    mut peripheral,
    runner,
    ..
} = stack.build();
}

Инициализация GATT-сервера

Макрос gatt_server инициализирует различные компоненты структуры Server, включая поддержку метода-конструктора new_with_config.

В этом примере мы настраиваем устройство на работу в роли Peripheral и задаём ему имя «implRust». Вы можете изменить его на любое другое.

#![allow(unused)]
fn main() {
let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig {
    name: "implRust",
    appearance: &appearance::power_device::GENERIC_POWER_DEVICE,
}))
.unwrap();
}

Задачи

После настройки сервера нужно запустить две параллельные задачи: задачу стека BLE и нашу прикладную логику. Метод join позволяет выполнять обе задачи одновременно.

#![allow(unused)]
fn main() {
let _ = join(ble_task(runner), async {
    loop {
        match advertise("impl Rust", &mut peripheral, &server).await {
            Ok(conn) => {
                // запускаем задачи при установлении соединения с central, чтобы они не работали, когда никто не подключён.
                let a = gatt_events_task(&server, &conn);
                let b = custom_task(&server, &conn, &stack);
                // работаем, пока не завершится любая из задач (обычно из-за разрыва соединения),
                // затем возвращаемся в состояние рекламы.
                select(a, b).await;
            }
            Err(e) => {
                let e = defmt::Debug2Format(&e);
                panic!("[adv] error: {:?}", e);
            }
        }
    }
})
.await;
}

Вскоре мы определим и разберём функцию advertise. Она возвращает Result с GATT-соединением или ошибкой BLE host. Если соединение успешно получено, запускаем две задачи, привязанные к этому соединению:

  • gatt_events_task: обрабатывает входящие GATT-запросы (чтение и запись) от подключённого central-устройства
  • custom_task: выполняет нашу прикладную логику во время соединения

Функция select выполняет обе задачи одновременно и завершается, когда завершается любая из них, обычно при разрыве соединения. После этого цикл возвращается в состояние рекламы, и устройство снова становится обнаруживаемым для новых подключений.

Задача стека BLE

Эта задача постоянно работает в фоне вместе с другими задачами BLE и поддерживает стек Bluetooth в активном состоянии

#![allow(unused)]
fn main() {
async fn ble_task<C: Controller, P: PacketPool>(mut runner: Runner<'_, C, P>) {
    loop {
        if let Err(e) = runner.run().await {
            let e = defmt::Debug2Format(&e);
            panic!("[ble_task] error: {:?}", e);
        }
    }
}
}

Реклама BLE (advertising)

Реклама BLE (advertising) — это способ, которым устройство сообщает о себе ближайшим устройствам (например, телефонам или компьютерам) и передаёт информацию, чтобы к нему можно было подключиться. Устройство рассылает своё имя, доступные сервисы и возможности.

Когда central-устройство инициирует подключение, функция принимает его и возвращает экземпляр GATT-соединения, который используется для обмена данными с подключённым устройством.

#![allow(unused)]
fn main() {
/// Создаёт advertiser для подключения к BLE Central и ждёт, пока тот подключится.
async fn advertise<'values, 'server, C: Controller>(
    name: &'values str,
    peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
    server: &'server Server<'values>,
) -> Result<GattConnection<'values, 'server, DefaultPacketPool>, BleHostError<C::Error>> {
    let mut advertiser_data = [0; 31];
    let len = AdStructure::encode_slice(
        &[
            AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
            AdStructure::CompleteLocalName(name.as_bytes()),
        ],
        &mut advertiser_data[..],
    )?;
    let advertiser = peripheral
        .advertise(
            &Default::default(),
            Advertisement::ConnectableScannableUndirected {
                adv_data: &advertiser_data[..len],
                scan_data: &[],
            },
        )
        .await?;
    info!("[adv] advertising");
    let conn = advertiser.accept().await?.with_attribute_server(server)?;
    info!("[adv] connection established");
    Ok(conn)
}
}

Разберём эту функцию по шагам.

Подготовка данных рекламы

#![allow(unused)]
fn main() {
let mut advertiser_data = [0; 31];
 
let len = AdStructure::encode_slice(
    &[
        AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
        AdStructure::CompleteLocalName(name.as_bytes()),
    ],
    &mut advertiser_data[..],
)?;
}

Создаём буфер и кодируем в него две части информации:

  • Flags: LE_GENERAL_DISCOVERABLE делает устройство видимым для всех сканеров, а BR_EDR_NOT_SUPPORTED указывает, что это устройство только для BLE (без классического Bluetooth). Подробнее о флагах — здесь.
  • Имя устройства: полное локальное имя, которое появляется при поиске устройств

Функция encode_slice упаковывает эти данные в правильный формат рекламы BLE и возвращает фактически использованную длину.

Запуск рекламы

#![allow(unused)]
fn main() {
let advertiser = peripheral
    .advertise(
        &Default::default(),
        Advertisement::ConnectableScannableUndirected {
            adv_data: &advertiser_data[..len],
            scan_data: &[],
        },
    )
    .await?;
}

Запускаем рекламу в режиме ConnectableScannableUndirected, а это значит:

  • Connectable: central-устройства могут подключиться к нам
  • Scannable: central-устройства могут запросить дополнительную информацию (хотя здесь мы передаём пустой scan_data)
  • Undirected: мы вещаем всем, а не нацеливаемся на конкретное устройство

Теперь устройство видно ближайшим BLE-сканерам.

Приём подключений

#![allow(unused)]
fn main() {
let conn = advertiser.accept().await?.with_attribute_server(server)?;
Ok(conn)
}

Вызов accept() ждёт, пока central-устройство инициирует подключение. После подключения мы преобразуем соединение в GattConnection с помощью with_attribute_server(), которая подключает наш GATT-сервер (содержащий наши сервисы и характеристики) к соединению.

GATT-события

Задача GATT-событий обрабатывает входящие запросы от подключённого central-устройства. Она выполняет операции чтения и записи характеристик и отвечает на них, пока соединение не закроется.

#![allow(unused)]
fn main() {
/// Обрабатывает события, пока соединение не закроется.
///
/// Эта функция обрабатывает GATT-события.
/// Так мы обрабатываем запросы на чтение и запись.
async fn gatt_events_task<P: PacketPool>(
    server: &Server<'_>,
    conn: &GattConnection<'_, '_, P>,
) -> Result<(), Error> {
    let sensor_data = server.sensor_service.sensor_data;

    let reason = loop {
        match conn.next().await {
            GattConnectionEvent::Disconnected { reason } => break reason,
            GattConnectionEvent::Gatt { event } => {
                match &event {
                    GattEvent::Read(event) => {
                        if event.handle() == sensor_data.handle {
                            let value = server.get(&sensor_data);
                            info!(
                                "[gatt] Read Event to Sensor Data Characteristic: {:?}",
                                value
                            );
                        }
                    }
                    GattEvent::Write(event) => {
                        if event.handle() == sensor_data.handle {
                            info!(
                                "[gatt] Write Event to Sensor Data Characteristic: {:?}",
                                event.data()
                            );
                        }
                    }
                    _ => {}
                };
                // Этот шаг также выполняется в drop(), но явная запись нужна, чтобы гарантировать отправку ответа.
                // (так гарантируется, что ответ будет отправлен).
                match event.accept() {
                    Ok(reply) => reply.send().await,
                    Err(e) => warn!("[gatt] error sending response: {:?}", e),
                };
            }
            _ => {} // игнорируем прочие события GATT-соединения
        }
    };

    info!("[gatt] disconnected: {:?}", reason);
    Ok(())
}
}

Разберём эту функцию по шагам.

Цикл обработки событий

#![allow(unused)]
fn main() {
let sensor_data = server.sensor_service.sensor_data;

let reason = loop {
    match conn.next().await {
        GattConnectionEvent::Disconnected { reason } => break reason,
        GattConnectionEvent::Gatt { event } => {
            // ... обработка события
        }
        _ => {} // игнорируем прочие события GATT-соединения
    }
};
info!("[gatt] disconnected: {:?}", reason);
}

Сначала получаем ссылку на нашу характеристику данных датчика, затем входим в цикл, который ждёт событий соединения. Цикл продолжается, пока не произойдёт разрыв соединения; тогда он завершается и записывает причину разрыва в лог.

Обработка событий чтения

#![allow(unused)]
fn main() {
GattEvent::Read(event) => {
    if event.handle() == sensor_data.handle {
        let value = server.get(&sensor_data);
        info!(
            "[gatt] Read Event to Sensor Data Characteristic: {:?}",
            value
        );
    }
}
}

Когда central-устройство читает характеристику, мы проверяем, совпадает ли её дескриптор (handle) с нашей характеристикой данных датчика. Если совпадает, получаем текущее значение с сервера и выводим его в лог. Библиотека сама отправляет значение при вызове accept и send.

Обработка событий записи

#![allow(unused)]
fn main() {
GattEvent::Write(event) => {
    if event.handle() == sensor_data.handle {
        info!(
            "[gatt] Write Event to Sensor Data Characteristic: {:?}",
            event.data()
        );
    }
}
}

Когда central-устройство пишет в характеристику, мы проверяем, пишет ли оно в нашу характеристику данных датчика. Если да, выводим в лог записываемые данные. Непосредственно запись обработается, когда мы вызовем accept() для события.

Отправка ответов

#![allow(unused)]
fn main() {
match event.accept() {
    Ok(reply) => reply.send().await,
    Err(e) => warn!("[gatt] error sending response: {:?}", e),
};
}

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

Уведомления (Notifier)

Эта задача показывает, как отправлять данные подключённому central-устройству. Представьте, что это данные датчика, которые вы периодически собираете и отправляете на мобильное устройство или другое устройство. В этом примере мы имитируем показания датчика: каждые 2 секунды увеличиваем счётчик и отправляем уведомления подключённому устройству.

#![allow(unused)]
fn main() {
/// Пример задачи, использующей интерфейс уведомлений BLE.
/// Задача отправляет подключённому central значение счётчика каждые 2 секунды.
/// Также каждые 2 секунды читает значение RSSI.
/// и завершается, когда central закрывает соединение или происходит ошибка.
async fn custom_task<C: Controller, P: PacketPool>(
    server: &Server<'_>,
    conn: &GattConnection<'_, '_, P>,
    stack: &Stack<'_, C, P>,
) {
    let mut tick: u8 = 0;
    let sensor_data = server.sensor_service.sensor_data;
    loop {
        tick = tick.wrapping_add(1);
        info!("[custom_task] notifying connection of tick {}", tick);
        if sensor_data.notify(conn, &tick).await.is_err() {
            info!("[custom_task] error notifying connection");
            break;
        };
        // читаем RSSI (индикатор уровня принятого сигнала) соединения.
        if let Ok(rssi) = conn.raw().rssi(stack).await {
            info!("[custom_task] RSSI: {:?}", rssi);
        } else {
            info!("[custom_task] error getting RSSI");
            break;
        };
        Timer::after_secs(2).await;
    }
}
}

Отправка уведомлений

#![allow(unused)]
fn main() {
let mut tick: u8 = 0;
let sensor_data = server.sensor_service.sensor_data;
loop {
    tick = tick.wrapping_add(1);
    info!("[custom_task] notifying connection of tick {}", tick);
    if sensor_data.notify(conn, &tick).await.is_err() {
        info!("[custom_task] error notifying connection");
        break;
    };
    // ...
    Timer::after_secs(2).await;
}
}

Мы ведём счётчик, который увеличивается на каждой итерации цикла. Метод notify() отправляет текущее значение счётчика подключённому central-устройству. Если уведомление не удалось отправить (обычно потому, что соединение разорвано), выходим из цикла.

Чтение уровня сигнала

#![allow(unused)]
fn main() {
if let Ok(rssi) = conn.raw().rssi(stack).await {
    info!("[custom_task] RSSI: {:?}", rssi);
} else {
    info!("[custom_task] error getting RSSI");
    break;
};
}

RSSI (Received Signal Strength Indicator, индикатор уровня принятого сигнала) измеряет качество соединения в дБм. Мы читаем это значение, чтобы отслеживать силу соединения. Если прочитать RSSI не удалось, обычно это значит, что соединение потеряно, и мы завершаем задачу.

Подключение к нашему BLE-серверу через мобильное приложение nRF Connect

Когда прошивка загрузится в ESP32, откройте мобильное приложение nRF Connect.

Найдите в списке Bluetooth-имя, которое мы задали (у меня оно «implRust»), и подключитесь к нему.

BLE, мобильное приложение nRF Connect

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

BLE, сервисы и характеристики

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

BLE, подписка на уведомления

Клонирование существующего проекта

Вы можете клонировать (или посмотреть) созданный мной проект и перейти в папку bluetooth-low-energy.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/bluetooth-low-energy/

Полный код модуля ble

#![allow(unused)]
fn main() {
use defmt::{info, warn};

use embassy_futures::join::join;
use embassy_futures::select::select;

use embassy_time::Timer;

// BLE:
use trouble_host::prelude::*;

/// Максимальное число подключений
const CONNECTIONS_MAX: usize = 1;

/// Максимальное число каналов L2CAP.
const L2CAP_CHANNELS_MAX: usize = 1;

// Определение GATT-сервера
#[gatt_server]
struct Server {
    sensor_service: SensorService,
}

/// Сервис батареи
#[gatt_service(uuid = "a9c81b72-0f7a-4c59-b0a8-425e3bcf0a0e")]
struct SensorService {
    #[characteristic(uuid = "13c0ef83-09bd-4767-97cb-ee46224ae6db", read, notify)]
    sensor_data: u8,

    #[characteristic(uuid = "c79b2ca7-f39d-4060-8168-816fa26737b7", write, read)]
    sensor_settings: bool,
}

/// Запускает стек BLE.
pub async fn run<C>(controller: C)
where
    C: Controller,
{
    // Фиксированный «случайный» адрес удобен для тестирования. В реальных сценариях можно использовать, например, 6-байтовый MAC-адрес
    // (способ его получения зависит от платформы).
    let address: Address = Address::random([0xff, 0x8f, 0x1a, 0x05, 0xe4, 0xff]);
    info!("Our address = {:?}", address);

    let mut resources: HostResources<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> =
        HostResources::new();
    let stack = trouble_host::new(controller, &mut resources).set_random_address(address);
    let Host {
        mut peripheral,
        runner,
        ..
    } = stack.build();

    info!("Starting advertising and GATT service");
    let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig {
        name: "implRust",
        appearance: &appearance::power_device::GENERIC_POWER_DEVICE,
    }))
    .unwrap();

    let _ = join(ble_task(runner), async {
        loop {
            match advertise("impl Rust", &mut peripheral, &server).await {
                Ok(conn) => {
                    // запускаем задачи при установлении соединения с central, чтобы они не работали, когда никто не подключён.
                    let a = gatt_events_task(&server, &conn);
                    let b = custom_task(&server, &conn, &stack);
                    // работаем, пока не завершится любая из задач (обычно из-за разрыва соединения),
                    // затем возвращаемся в состояние рекламы.
                    select(a, b).await;
                }
                Err(e) => {
                    let e = defmt::Debug2Format(&e);
                    panic!("[adv] error: {:?}", e);
                }
            }
        }
    })
    .await;
}

/// Фоновая задача, которая должна работать постоянно вместе с остальными задачами BLE.
///
/// ## Альтернатива
///
/// Если бы не требовалось делать эту задачу универсальной, её можно было бы запустить статически, например так:
///
/// ```rust,ignore
///
/// #[embassy_executor::task]
/// async fn ble_task(mut runner: Runner<'static, SoftdeviceController<'static>>) {
///     runner.run().await;
/// }
///
/// spawner.must_spawn(ble_task(runner));
/// ```
async fn ble_task<C: Controller, P: PacketPool>(mut runner: Runner<'_, C, P>) {
    loop {
        if let Err(e) = runner.run().await {
            let e = defmt::Debug2Format(&e);
            panic!("[ble_task] error: {:?}", e);
        }
    }
}

/// Обрабатывает события, пока соединение не закроется.
///
/// Эта функция обрабатывает GATT-события.
/// Так мы обрабатываем запросы на чтение и запись.
async fn gatt_events_task<P: PacketPool>(
    server: &Server<'_>,
    conn: &GattConnection<'_, '_, P>,
) -> Result<(), Error> {
    let sensor_data = server.sensor_service.sensor_data;
    let reason = loop {
        match conn.next().await {
            GattConnectionEvent::Disconnected { reason } => break reason,
            GattConnectionEvent::Gatt { event } => {
                match &event {
                    GattEvent::Read(event) => {
                        if event.handle() == sensor_data.handle {
                            let value = server.get(&sensor_data);
                            info!(
                                "[gatt] Read Event to Sensor Data Characteristic: {:?}",
                                value
                            );
                        }
                    }
                    GattEvent::Write(event) => {
                        if event.handle() == sensor_data.handle {
                            info!(
                                "[gatt] Write Event to Sensor Data Characteristic: {:?}",
                                event.data()
                            );
                        }
                    }
                    _ => {}
                };
                // Этот шаг также выполняется в drop(), но явная запись нужна, чтобы гарантировать отправку ответа.
                // (так гарантируется, что ответ будет отправлен).
                match event.accept() {
                    Ok(reply) => reply.send().await,
                    Err(e) => warn!("[gatt] error sending response: {:?}", e),
                };
            }
            _ => {} // игнорируем прочие события GATT-соединения
        }
    };
    info!("[gatt] disconnected: {:?}", reason);
    Ok(())
}

/// Создаёт advertiser для подключения к BLE Central и ждёт, пока тот подключится.
async fn advertise<'values, 'server, C: Controller>(
    name: &'values str,
    peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
    server: &'server Server<'values>,
) -> Result<GattConnection<'values, 'server, DefaultPacketPool>, BleHostError<C::Error>> {
    let mut advertiser_data = [0; 31];
    let len = AdStructure::encode_slice(
        &[
            AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
            AdStructure::CompleteLocalName(name.as_bytes()),
        ],
        &mut advertiser_data[..],
    )?;
    let advertiser = peripheral
        .advertise(
            &Default::default(),
            Advertisement::ConnectableScannableUndirected {
                adv_data: &advertiser_data[..len],
                scan_data: &[],
            },
        )
        .await?;
    info!("[adv] advertising");
    let conn = advertiser.accept().await?.with_attribute_server(server)?;
    info!("[adv] connection established");
    Ok(conn)
}

/// Пример задачи, использующей интерфейс уведомлений BLE.
/// Задача отправляет подключённому central значение счётчика каждые 2 секунды.
/// Также каждые 2 секунды читает значение RSSI.
/// и завершается, когда central закрывает соединение или происходит ошибка.
async fn custom_task<C: Controller, P: PacketPool>(
    server: &Server<'_>,
    conn: &GattConnection<'_, '_, P>,
    stack: &Stack<'_, C, P>,
) {
    let mut tick: u8 = 0;
    let sensor_data = server.sensor_service.sensor_data;
    loop {
        tick = tick.wrapping_add(1);
        info!("[custom_task] notifying connection of tick {}", tick);
        if sensor_data.notify(conn, &tick).await.is_err() {
            info!("[custom_task] error notifying connection");
            break;
        };
        // читаем RSSI (индикатор уровня принятого сигнала) соединения.
        if let Ok(rssi) = conn.raw().rssi(stack).await {
            info!("[custom_task] RSSI: {:?}", rssi);
        } else {
            info!("[custom_task] error getting RSSI");
            break;
        };
        Timer::after_secs(2).await;
    }
}
}

ЖК-дисплей (LCD)

В этом разделе мы будем использовать символьные ЖК-дисплеи (LCD, Liquid Crystal Display) на основе совместимого с Hitachi HD44780 контроллера. Такие дисплеи можно увидеть в принтерах, цифровых часах, микроволновках, стиральных машинах, кондиционерах и других бытовых приборах. Они также используются в копировальных аппаратах, факсах и маршрутизаторах.

Такой дисплей может выводить ASCII-символы и до 8 пользовательских символов.

Варианты

Бывают разные варианты, например 16x2 (16 столбцов, 2 строки) и 20x4 (20 столбцов, 4 строки), а также отличающиеся цветом подсветки (синяя, жёлтая или зелёная). У моего дисплея белые символы на синей подсветке. Однако можно выбрать любой вариант, это почти не повлияет на код. У большинства таких вариантов 16 выводов.

Варианты с I2C

Некоторые варианты оснащены адаптером-переходником I2C, поэтому для связи можно использовать I2C. Главное преимущество варианта с I2C — меньшее количество соединений. Мы будем использовать ЖК-дисплей с адаптером I2C.

Если вас интересуют ЖК-дисплеи с параллельным интерфейсом, см. раздел о LCD в версии этой книги для Pico. Однако имейте в виду, что параллельный интерфейс требует больше выводов GPIO (зато такие дисплеи дешевле).

LCD1602 с I2C

Поэтому при покупке ЖК-модуля убедитесь, что у него всего четыре вывода и есть адаптер I2C, как показано на изображении.

Аппаратные требования

Нам понадобится ЖК-дисплей LCD1602. Рекомендуется модуль 16x2 с адаптером I2C, чтобы вы могли следовать инструкциям без изменений, хотя дисплеи других размеров работают так же.

Преобразователь уровней

4-канальный двунаправленный преобразователь логических уровней 3,3–5 В

Выводы GPIO ESP32 допускают напряжение до 3,6 В, а это значит, что они небезопасны при сигналах 5 В. Подача более высокого напряжения, например 5 В, на эти выводы может повредить плату. Многие LCD1602 с адаптером I2C рассчитаны на работу от 5 В, и при прямом подключении к ESP32 возникает несогласованность напряжений.

Чтобы безопасно соединить ESP32 и ЖК-дисплей, нужно устранить эту разницу напряжений. Для этого используется преобразователь уровней. Двунаправленный преобразователь логических уровней для I2C позволяет устройствам на 3,3 В и 5 В безопасно обмениваться данными и защищает выводы GPIO ESP32. Такие модули недороги и часто продаются под названием «4 Channel (I2C) 3.3V-5V Bi-Directional Logic Level Converter».

Кроме того, можно питать ЖК-дисплей напряжением 3,3 В. Это решает проблему напряжения, но подсветка и контраст дисплея будут заметно тусклее.

Даташиты

Как это работает?

Жидкокристаллический дисплей (LCD) использует жидкие кристаллы для управления светом. Когда подаётся электричество, кристаллы меняют ориентацию: они либо пропускают свет, либо блокируют его, формируя изображения или текст. Подсветка освещает экран, а цветные субпиксели (красный, зелёный и синий) смешиваются, образуя различные цвета. Кристаллы также могут стать непрозрачными в определённых областях, блокируя подсветку и создавая тёмные участки для отображения символов.

ЖК-дисплей 16x2 и матрица пикселей 5x8

ЖК-дисплей 16x2 имеет 2 строки и 16 столбцов, поэтому может отобразить 32 символа. Каждый символ состоит из сетки пикселей 5x8: 5 столбцов и 8 строк пикселей формируют форму символа. Эта сетка используется для отображения текста и простых символов на экране.

lcd1602

Вывод текста и пользовательских символов на ЖК-дисплее 16x2

Рисовать пиксели вручную не нужно: это делает микросхема HD44780, которая автоматически сопоставляет ASCII-символы с сеткой 5x8.

Однако если вы хотите создать пользовательские символы или значки, нужно самостоятельно задать шаблон 5x8. Этот шаблон сохраняется в памяти ЖК-дисплея, и после его определения вы можете использовать пользовательский символ. Имейте в виду: одновременно можно сохранить не более 8 пользовательских символов.

Режим передачи данных

ЖК-модуль (LCM, Liquid Crystal Module) поддерживает два режима передачи данных: 8-битный и 4-битный. В 8-битном режиме данные передаются целым байтом по всем линиям данных. В 4-битном режиме используются только старшие биты данных, и данные передаются по полубайтам (nibble). Хотя 8-битный режим быстрее, у него есть недостаток: он требует много проводов, что быстро исчерпывает выводы GPIO микроконтроллера. Чтобы минимизировать количество проводов, мы будем использовать 4-битный режим.

Регулировка контраста

Когда вы включаете ЖК-дисплей, вы должны увидеть точечную матрицу. Если после запуска программы текст нечёткий, просто подкрутите потенциометр на интерфейсной плате I2C, чтобы настроить контраст.

lcd1602

Ссылки:

Раскладка выводов

У I2C-варианта ЖК-модуля всего 4 вывода.

lcd1602
Вывод Обозначение Описание
1 VCC Питание (обычно 5 В)
2 GND Земля
3 SDA Линия данных для связи I2C
4 SCL Линия тактирования для связи I2C

Подключение ЖК-дисплея (LCD1602) к ESP32

Мы подключим символьный дисплей LCD1602 с адаптером I2C к ESP32. С точки зрения проводки схема выглядит простой, потому что нужны всего четыре соединения: питание, земля, SDA и SCL. Однако, несмотря на небольшое количество проводов, есть важная деталь по напряжению, которую нужно правильно учесть до подключения.

Проблема совместимости напряжений

Допустимое напряжение на выводах GPIO ESP32 составляет 3,6 В. Если напряжение на выводе GPIO превышает 3,6 В, оно выходит за безопасный рабочий диапазон и может повредить микросхему.

Большинство модулей LCD1602 с адаптером I2C рассчитаны на работу от 5 В. Адаптер I2C обычно имеет подтягивающие резисторы, подключённые к напряжению питания. При питании от 5 В это означает, что в состоянии покоя линии SDA и SCL находятся на уровне 5 В.

Если SDA и SCL такого модуля подключить напрямую к ESP32, выводы GPIO ESP32 окажутся под напряжением 5 В. Именно эту проблему нам и нужно решить.

Распространённый обходной путь, который вы увидите в интернете

Многие онлайн-руководства предлагают питать LCD1602 и его адаптер I2C от 5 В и подключать SDA и SCL напрямую к ESP32. Я сам проверял такую схему, и она работает. Однако с электрической точки зрения это небезопасно, а длительная эксплуатация может повредить выводы GPIO ESP32.

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

Note

Эта тема часто обсуждается в сообществе ESP32. Одни пользователи сообщают, что входы 5 В в любительских проектах, похоже, работают, а другие указывают, что такое поведение не описано в документации Espressif и на него не стоит полагаться, особенно в коммерческих или долговременных разработках. Одно из связанных обсуждений можно найти здесь: https://www.reddit.com/r/esp32/comments/1j5p0lb/can_the_espwroom32_read_5v_io_inputs/

Простой, но достаточно безопасный подход: питание всего 3,3 В

Для демонстраций, экспериментов и учебных проектов самый простой и безопасный способ — питать ЖК-модуль LCD1602 с I2C от источника 3,3 В на ESP32, а не от 5 В.

Когда адаптер питается от 3,3 В, его подтягивающие резисторы I2C подтягивают SDA и SCL к 3,3 В, а не к 5 В. Это сразу устраняет проблему совместимости напряжений, и SDA и SCL можно подключить напрямую к ESP32.

Платой за это будут более тусклый контраст и подсветка дисплея. При питании от 3,3 В дисплей обычно всё ещё читаем при обычном комнатном освещении, чего достаточно для экспериментов и базовой проверки.

Такой подход не требует дополнительных компонентов и не нагружает выводы GPIO ESP32.

Вывод ЖК-дисплея Провод Вывод ESP32 Примечания
GND
GND Земля
VCC
3.3 Питание 3,3 В
SCL
GPIO 18 Подключает сигнал тактирования (SCL) для связи I2C.
SDA
GPIO 23 Подключает сигнал данных (SDA) для связи I2C.

lcd1602

Лучший подход: использование преобразователя уровней

Если нужна полная яркость подсветки или если ваш ЖК-модуль не работает надёжно при 3,3 В, его необходимо питать от 5 В. Но тогда нужно защитить ESP32 от сигналов 5 В.

Решение — двунаправленный преобразователь логических уровней (также называемый level shifter). Этот небольшой модуль преобразует сигналы между 3,3 В и 5 В в обоих направлениях.

ESP32 подключается к стороне 3,3 В преобразователя, а ЖК-дисплей — к стороне 5 В. Преобразователь гарантирует, что ESP32 всегда видит только сигналы 3,3 В, а ЖК-дисплей получает сигналы 5 В, которые ему нужны.

Это электрически правильный и безопасный способ, но он добавляет несколько проводов и требует дополнительного модуля.


lcd1602

Схема может показаться немного запутанной на первый взгляд, но идея проста, если разобрать её по частям.

Подключение питания (сначала подключите эти провода)

Сначала подключите выход 3,3 В ESP32 к выводу, помеченному LV (Low Voltage, низкое напряжение), на преобразователе. Это задаёт логический уровень для стороны ESP32. Затем подключите питание 5 В ESP32 (то есть VIN на плате ESP32 DevKit V1) к выводу, помеченному HV (High Voltage, высокое напряжение), на преобразователе. Вывод VCC ЖК-дисплея тоже питается от того же источника 5 В.

Земля должна быть общей, поэтому соедините вместе землю ESP32, землю преобразователя и землю ЖК-дисплея.

Откуда Вывод Провод Куда Вывод Назначение
ESP32 GND
Преобразователь уровней GND Общая земля
Преобразователь уровней GND
ЖК-дисплей GND Общая земля
ESP32 3.3V
Преобразователь уровней LV Опорный логический уровень низкого напряжения
ESP32 VIN / 5V
Преобразователь уровней HV Опорный логический уровень высокого напряжения
ESP32 VIN / 5V
ЖК-дисплей VCC Питание 5 В для ЖК-дисплея

Подключение данных (связь I2C)

Теперь подключим линии сигналов I2C. Выводы GPIO ESP32 всегда должны подключаться к выводам преобразователя, помеченным LVx, а выводы I2C ЖК-дисплея — к соответствующим выводам, помеченным HVx.

В этом примере вывод SDA ESP32 подключается к LV1, а вывод SDA ЖК-дисплея — к HV1. Вывод SCL ESP32 подключается к LV2, а вывод SCL ЖК-дисплея — к HV2. Конкретные номера GPIO для SDA и SCL зависят от вашей платы ESP32 и конфигурации программы.

Откуда Вывод Провод Куда Вывод Назначение
ESP32 GPIO 23 (SDA)
Преобразователь уровней LV1 SDA (данные), сторона ESP32
Преобразователь уровней HV1
ЖК-дисплей SDA SDA (данные), сторона ЖК-дисплея
ESP32 GPIO 18 (SCL)
Преобразователь уровней LV2 SCL (тактирование), сторона ESP32
Преобразователь уровней HV2
ЖК-дисплей SCL SCL (тактирование), сторона ЖК-дисплея

Как это работает

Проще говоря, когда ESP32 обменивается данными по линии I2C, сторона LVx работает на 3,3 В, а преобразователь выдаёт сигнал 5 В на соответствующей стороне HVx для ЖК-дисплея. Когда ЖК-дисплей обменивается данными на 5 В, преобразователь переводит этот сигнал обратно, так что ESP32 всегда видит на стороне LVx только 3,3 В. Таким образом, оба устройства работают на требуемом им напряжении, не перегружая выводы GPIO ESP32.

«Hello, Rust!» на ЖК-дисплее

В этой программе мы просто выведем текст «Hello, Rust!» на ЖК-дисплей.

Драйверы HD44780

Изучая вопрос, я нашёл много крейтов Rust для управления ЖК-дисплеем, но выделялись два, которые хорошо работают. В этой программе мы начнём с использования крейта hd44780-driver.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 lcd-hello

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.». Только после этого вы сможете выбрать поддержку фреймворка Embassy.
  • Выберите опцию «Adds embassy framework support.»

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

Мы будем использовать крейт hd44780-driver и возьмём самую свежую версию прямо из репозитория GitHub, указав нужный коммит Git с помощью атрибута rev. Чтобы включить поддержку async, можно указать feature embedded-hal-async.

# hd44780-driver = "0.4.0"
hd44780-driver = { git = "https://github.com/JohnDoneth/hd44780-driver", rev = "9009f2c24771ba0a20f8f7534471c9869188f76c", features = [
    "embedded-hal-async",
] }

Импорты

Сначала добавим необходимые импорты:

#![allow(unused)]
fn main() {
// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Драйвер HD44780
use hd44780_driver::HD44780;
use hd44780_driver::memory_map::MemoryMap1602;
use hd44780_driver::setup::DisplayOptionsI2C;
}

Инициализация I2C

Мы настроили интерфейс I2C для соединения ESP32 с ЖК-модулем. Шину I2C мы запускаем на частоте 400 кГц. Вывод GPIO18 назначен на SCL (Serial Clock Line, линия тактирования), а GPIO23 — на SDA (Serial Data Line, линия данных). Также мы включили асинхронную работу.

#![allow(unused)]
fn main() {
let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();
}

Инициализация дисплея

Нужно указать адрес I2C ЖК-модуля. Обычно он приведён в спецификации модуля. Самый распространённый — 0x27. Лучше проверить его по даташиту или сканированием I2C-устройств.

#![allow(unused)]
fn main() {
let i2c_address = 0x27;

let Ok(mut lcd) = HD44780::new(
    DisplayOptionsI2C::new(MemoryMap1602::new()).with_i2c_bus(i2c_bus, i2c_address),
    &mut embassy_time::Delay,
) else {
    panic!("failed to initialize display");
};
}

Подготовка дисплея

Сбросим дисплей в состояние по умолчанию, переведём курсор в начало и очистим символы, которые есть на дисплее.

#![allow(unused)]
fn main() {
// Сбрасываем сдвиг дисплея и устанавливаем курсор в позицию 0
lcd.reset(&mut embassy_time::Delay).unwrap();

// Очищаем имеющиеся символы
lcd.clear(&mut embassy_time::Delay).unwrap();
}

Вывод текста на ЖК-дисплей

Выведем два текста на обеих строках ЖК-модуля.

#![allow(unused)]
fn main() {

// Выводим следующую строку
lcd.write_str("impl Rust", &mut embassy_time::Delay)
    .unwrap();

// Перемещаем курсор на вторую строку
lcd.set_cursor_xy((0, 1), &mut embassy_time::Delay).unwrap();

// Выводим строку на второй строке
lcd.write_str("Hello, Ferris!", &mut embassy_time::Delay)
    .unwrap();
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку lcd-hello.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/lcd-hello/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Драйвер HD44780
use hd44780_driver::HD44780;
use hd44780_driver::memory_map::MemoryMap1602;
use hd44780_driver::setup::DisplayOptionsI2C;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let i2c_address = 0x27;

    let Ok(mut lcd) = HD44780::new(
        DisplayOptionsI2C::new(MemoryMap1602::new()).with_i2c_bus(i2c_bus, i2c_address),
        &mut embassy_time::Delay,
    ) else {
        panic!("failed to initialize display");
    };

    // Сбрасываем сдвиг дисплея и устанавливаем курсор в позицию 0
    lcd.reset(&mut embassy_time::Delay).unwrap();

    // Очищаем имеющиеся символы
    lcd.clear(&mut embassy_time::Delay).unwrap();

    // Выводим следующую строку
    lcd.write_str("impl Rust", &mut embassy_time::Delay)
        .unwrap();

    // Перемещаем курсор на вторую строку
    lcd.set_cursor_xy((0, 1), &mut embassy_time::Delay).unwrap();

    // Выводим строку на второй строке
    lcd.write_str("Hello, Ferris!", &mut embassy_time::Delay)
        .unwrap();

    loop {
        info!("Hello world!");
        Timer::after(Duration::from_secs(1)).await;
    }
}

Поддерживаемые символы

В даташите HD44780 вы найдёте две таблицы символов, соответствующие двум версиям ПЗУ (A00 и A02). Чтобы определить, какое ПЗУ использует ваш дисплей, попробуйте уникальные символы из обеих таблиц. Тот, который отобразится правильно, и укажет версию ПЗУ. Определив её, нужно смотреть только в соответствующую таблицу.

В моём случае ЖК-модуль основан на ПЗУ версии A00. Я приведу таблицу A00 и объясню, как её читать, хотя логика чтения одинакова для обеих версий.

lcd1602

Это 8-битный символ, где сначала идут старшие 4 бита, затем младшие 4 бита, образуя полный байт символа. В справочной таблице старшие 4 бита соответствуют столбцам, а младшие 4 бита — строкам.

Например, чтобы получить двоичное представление символа «#», старшие 4 бита равны 0010, а младшие 4 бита — 0011. Объединив их, получаем полное двоичное значение 00100011. В Rust это значение можно записать как в двоичном виде (0b00100011), так и в шестнадцатеричном (0x23).

Крейт hd44780-driver

В используемом нами крейте hd44780-driver символы можно записывать напрямую, как один байт или как последовательность байтов.

Запись одного байта

#![allow(unused)]
fn main() {
lcd.write_byte(0x23, &mut timer).unwrap();
lcd.write_byte(0b00100011, &mut timer).unwrap();
}

Запись нескольких байтов

#![allow(unused)]
fn main() {
lcd.write_bytes(&[0x23, 0x24], &mut timer).unwrap();
}

Пользовательские символы

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

Каждый символ — это сетка 8x8, где каждая строка представлена одним 8-битным значением (u8). Получается 8 байт на символ (8 строк × 1 байт на строку). Поэтому при общем объёме 64 байта можно сохранить не более 8 пользовательских символов (8 символов × 8 байт = 64 байта).

сетка битов пользовательского символа

Примечание: как вы помните, в нашем ЖК-модуле каждый символ представлен сеткой 5x8. Но ведь мы говорили, что для символов нужна сетка 8x8? Да, всё верно: нужна память 8 x 8 (8 байт), но в каждой строке используются только 5 бит. Три старших бита в каждой строке остаются нулевыми.

Генератор пользовательских символов LCD (сетка 5x8)

Выберите ячейки сетки, чтобы создать символ. По мере выбора ячеек соответствующие биты в массиве байтов будут обновляться.

Сгенерированный массив

Ferris на ЖК-дисплее

Давайте создадим Ferris (мою попытку изобразить краба; если у вас есть более удачный дизайн, присылайте pull request) одним символом. На самом деле для отображения одного символа можно объединить 4 или 6 соседних сеток. Творческий подход на ваше усмотрение, вы можете улучшить его как угодно.

Для создания этого символа мы воспользуемся генератором пользовательских символов с предыдущей страницы. Он даст нам байтовый массив, который можно использовать.

Ferris на ЖК-дисплее

Обратите внимание: предыдущий крейт hd44780-driver (на момент написания главы) не поддерживает пользовательские символы. Для этого можно использовать крейт liquid_crystal, который позволяет работать с пользовательскими символами.

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

Мы включим поддержку async (Embassy) для этого проекта. Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 lcd-custom

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.». Только после этого вы сможете выбрать поддержку фреймворка Embassy.
  • Выберите опцию «Adds embassy framework support.»

Сохраните настройки, нажав на клавиатуре «s».

Обновляем Cargo.toml

liquid_crystal = "0.2.0"

Импорты

Сначала добавим необходимые импорты:

#![allow(unused)]
fn main() {
// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Драйвер HD44780
use liquid_crystal::prelude::*;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::I2C;
}

Инициализация I2C

#![allow(unused)]
fn main() {
let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();
}

Инициализация интерфейса ЖК-дисплея

#![allow(unused)]
fn main() {
let mut i2c_interface = I2C::new(i2c_bus, 0x27);
let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
lcd.begin(&mut Delay);
}

Байтовый массив нашего сгенерированного символа

#![allow(unused)]
fn main() {
const FERRIS: [u8; 8] = [
    0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];
// Определяем символ
lcd.custom_char(&mut Delay, &FERRIS, 0);
}

Вывод символа

Вывод символа прост: нужно использовать перечисление CustomChar и передать индекс пользовательского символа. Мы определили только один пользовательский символ, он находится в позиции 0.

#![allow(unused)]
fn main() {
lcd.write(&mut Delay, CustomChar(0));
// обычный текст
lcd.write(&mut Delay, Text(" implRust!"));
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку lcd-custom.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/lcd-custom/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::Delay;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Драйвер HD44780
use liquid_crystal::I2C;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::prelude::*;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

const FERRIS: [u8; 8] = [
    0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let mut i2c_interface = I2C::new(i2c_bus, 0x27);
    let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
    lcd.begin(&mut Delay);

    // Определяем символ
    lcd.custom_char(&mut Delay, &FERRIS, 0);

    lcd.write(&mut Delay, CustomChar(0));
    lcd.write(&mut Delay, Text(" implRust!"));

    loop {
        info!("Hello world!");
        Timer::after(Duration::from_secs(1)).await;
    }
}

Генератор нескольких пользовательских символов

Используйте его, когда нужно объединить несколько сеток в один символ. Соседние ячейки дисплея 16x2 можно использовать для создания пользовательского символа. Пример символа, созданного с помощью этого генератора, и способ его использования в Rust показаны на следующей странице.

Сгенерированный массив

Несколько пользовательских символов

В этом упражнении мы выведем изображение Ferris, созданное с помощью генератора нескольких символов. Шаги создания проекта повторять не будем. Можно либо выполнить шаги из предыдущей главы, либо клонировать проект предыдущей главы и продолжить работу на его основе.

Я создал изображение Ferris с помощью 6 соседних сеток, используя генератор с предыдущей страницы. Вот код на Rust, который использует эти байтовые массивы.

пользовательские символы, объединённые из нескольких сеток

Сгенерированные байтовые массивы для символов

#![allow(unused)]
fn main() {
const SYMBOL1: [u8; 8] = [
    0b00110, 0b01000, 0b01110, 0b01000, 0b00100, 0b00011, 0b00100, 0b01000,
];

const SYMBOL2: [u8; 8] = [
    0b00000, 0b00000, 0b00000, 0b10001, 0b10001, 0b11111, 0b00000, 0b00000,
];

const SYMBOL3: [u8; 8] = [
    0b01100, 0b00010, 0b01110, 0b00010, 0b00100, 0b11000, 0b00100, 0b00010,
];

const SYMBOL4: [u8; 8] = [
    0b01000, 0b01000, 0b00100, 0b00011, 0b00001, 0b00010, 0b00101, 0b01000,
];

const SYMBOL5: [u8; 8] = [
    0b00000, 0b00000, 0b00000, 0b11111, 0b01010, 0b10001, 0b00000, 0b00000,
];

const SYMBOL6: [u8; 8] = [
    0b00010, 0b00010, 0b00100, 0b11000, 0b10000, 0b01000, 0b10100, 0b00010,
];
}

Объявляем их как символы

#![allow(unused)]
fn main() {
lcd.custom_char(&mut Delay, &SYMBOL1, 0);
lcd.custom_char(&mut Delay, &SYMBOL2, 1);
lcd.custom_char(&mut Delay, &SYMBOL3, 2);
lcd.custom_char(&mut Delay, &SYMBOL4, 3);
lcd.custom_char(&mut Delay, &SYMBOL5, 4);
lcd.custom_char(&mut Delay, &SYMBOL6, 5);
}

Вывод

Выведем первые 3 сетки в первую строку, а вторую половину — во вторую строку ЖК-дисплея.

#![allow(unused)]
fn main() {
lcd.set_cursor(&mut Delay, 0, 4)
    .write(&mut Delay, CustomChar(0))
    .write(&mut Delay, CustomChar(1))
    .write(&mut Delay, CustomChar(2));

lcd.set_cursor(&mut Delay, 1, 4)
    .write(&mut Delay, CustomChar(3))
    .write(&mut Delay, CustomChar(4))
    .write(&mut Delay, CustomChar(5));
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку lcd-custom-multi.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/lcd-custom-multi/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::Delay;
use embassy_time::{Duration, Timer};
use esp_hal::clock::CpuClock;
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// I2C
use esp_hal::i2c::master::Config as I2cConfig; // для удобства импортируем под псевдонимом
use esp_hal::i2c::master::I2c;
use esp_hal::time::Rate;

// Драйвер HD44780
use liquid_crystal::I2C;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::prelude::*;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

const SYMBOL1: [u8; 8] = [
    0b00110, 0b01000, 0b01110, 0b01000, 0b00100, 0b00011, 0b00100, 0b01000,
];

const SYMBOL2: [u8; 8] = [
    0b00000, 0b00000, 0b00000, 0b10001, 0b10001, 0b11111, 0b00000, 0b00000,
];

const SYMBOL3: [u8; 8] = [
    0b01100, 0b00010, 0b01110, 0b00010, 0b00100, 0b11000, 0b00100, 0b00010,
];

const SYMBOL4: [u8; 8] = [
    0b01000, 0b01000, 0b00100, 0b00011, 0b00001, 0b00010, 0b00101, 0b01000,
];

const SYMBOL5: [u8; 8] = [
    0b00000, 0b00000, 0b00000, 0b11111, 0b01010, 0b10001, 0b00000, 0b00000,
];

const SYMBOL6: [u8; 8] = [
    0b00010, 0b00010, 0b00100, 0b11000, 0b10000, 0b01000, 0b10100, 0b00010,
];

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let i2c_bus = I2c::new(
        peripherals.I2C0,
        // I2cConfig — псевдоним esp_hal::i2c::master::I2c::Config
        I2cConfig::default().with_frequency(Rate::from_khz(400)),
    )
    .unwrap()
    .with_scl(peripherals.GPIO18)
    .with_sda(peripherals.GPIO23)
    .into_async();

    let mut i2c_interface = I2C::new(i2c_bus, 0x27);
    let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
    lcd.begin(&mut Delay);

    // Определяем символы
    lcd.custom_char(&mut Delay, &SYMBOL1, 0);
    lcd.custom_char(&mut Delay, &SYMBOL2, 1);
    lcd.custom_char(&mut Delay, &SYMBOL3, 2);
    lcd.custom_char(&mut Delay, &SYMBOL4, 3);
    lcd.custom_char(&mut Delay, &SYMBOL5, 4);
    lcd.custom_char(&mut Delay, &SYMBOL6, 5);

    lcd.set_cursor(&mut Delay, 0, 4)
        .write(&mut Delay, CustomChar(0))
        .write(&mut Delay, CustomChar(1))
        .write(&mut Delay, CustomChar(2));

    lcd.set_cursor(&mut Delay, 1, 4)
        .write(&mut Delay, CustomChar(3))
        .write(&mut Delay, CustomChar(4))
        .write(&mut Delay, CustomChar(5));

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Индекс символов

Здесь приведён список пользовательских символов с соответствующими байтовыми массивами. Если вы придумали интересный символ и хотите добавить его в этот список, присылайте pull request. Пожалуйста, используйте генератор пользовательских символов, который приведён здесь, чтобы сохранить единообразие.

Название Превью Массив байтов
Сердце сердце [ 0b00000, 0b01010, 0b11111, 0b11111, 0b01110, 0b00100, 0b00000, 0b00000,]
Замок замок [ 0b01110, 0b10001, 0b10001, 0b11111, 0b11011, 0b11011, 0b11011, 0b11111, ]
Пустое сердце пустое сердце [ 0b00000, 0b01010, 0b10101, 0b10001, 0b10001, 0b01010, 0b00100, 0b00000, ]
Батарея батарея [ 0b01110, 0b11011, 0b10001, 0b10001, 0b10001, 0b11111, 0b11111, 0b11111, ]
Шина шина [ 0b01110, 0b11111, 0b10001, 0b10001, 0b11111, 0b10101, 0b11111, 0b01010, ]
Колокольчик колокольчик [ 0b00100, 0b01110, 0b01110, 0b01110, 0b11111, 0b00000, 0b00100, 0b00000, ]
Песочные часы песочные часы [ 0b00000, 0b11111, 0b10001, 0b01010, 0b00100, 0b01010, 0b10101, 0b11111, ]
Зарядка зарядка [ 0b01010, 0b01010, 0b11111, 0b10001, 0b10001, 0b01110, 0b00100, 0b00100, ]
Галочка галочка [ 0b00000, 0b00000, 0b00001, 0b00011, 0b10110, 0b11100, 0b01000, 0b00000, ]
Нота музыкальная нота [ 0b00011, 0b00010, 0b00010, 0b00010, 0b00010, 0b01110, 0b11110, 0b01110, ]

Модуль e-Ink (электронной бумаги)

e-Ink, он же e-Paper (или электронная бумага), — один из самых интересных модулей дисплеев, с которыми я сталкивался. Вы записываете текст или изображения на дисплей с помощью микроконтроллера, затем отключаете его, — и дисплей продолжает показывать то, что вы отправили, без источника питания. Серьёзно: никаких проводов, никаких батареек! Да, вы не ослышались. Это похоже на магию.

Где используются e-Ink дисплеи?

Вы, возможно, уже видели e-Ink дисплей и даже не заметили этого. Присмотритесь: наверняка вы найдёте их повсюду вокруг!

применение e-ink

Технология e-Ink чаще всего используется в электронных книгах, например Amazon Kindle, где бумажноподобный экран обеспечивает комфортное чтение и длительную работу от батареи. В розничных магазинах бумажные ценники заменяют e-Ink дисплеями, что делает обновление гораздо эффективнее. Также e-Ink можно встретить в расписаниях на автобусных остановках, табло в аэропортах, табличках на дверях комнат, устройствах умного дома, внутренних цифровых вывесках, меню и табличках в палатах и отделениях ухода за пациентами.

Знакомство с железом

Существует много вариантов, которые отличаются размером дисплея и поддерживаемыми цветами. Одни дисплеи чёрно-белые, другие поддерживают трёхцветный или многоцветный режим.

Мы будем использовать 1,54-дюймовый модуль e-Ink от Waveshare, который работает по интерфейсу SPI. Разрешение дисплея — 200 x 200, он выводит изображения в чёрно-белом режиме.

модуль e-ink 1,54 дюйма с интерфейсом SPI

Для этого проекта рекомендуем купить 1,54-дюймовый чёрно-белый модуль (v2.1). Эта версия самая дешёвая, поэтому доступна всем, а к тому же обеспечивает более высокую скорость обновления по сравнению с цветными вариантами. Следовать инструкциям будет легче, если использовать именно этот вариант.

Впрочем, вы можете выбрать любой размер или цвет дисплея: основные концепции останутся похожими.

Ресурсы

Как это работает?

В дисплеях E Ink используются крошечные капсулы, заполненные белыми частицами (с положительным зарядом) и чёрными частицами (с отрицательным зарядом), которые плавают в жидкости. Когда подаются электрические заряды, частицы перемещаются к верхней части капсул и формируют текст или изображения. Эти дисплеи используют окружающий свет для отображения содержимого, поэтому им не нужна подсветка. Благодаря этому они потребляют очень мало энергии. Энергия нужна только тогда, когда на экране нужно поменять отображаемое содержимое.

Режим обновления

Один из главных недостатков этого модуля — относительно низкая скорость обновления. При обновлении дисплей мерцает между чёрным и белым несколько раз, прежде чем установиться на итоговом изображении (количество мерцаний зависит от времени обновления). Когда я впервые использовал модуль, я запутался и подумал, что делаю что-то не так. Однако после дополнительного изучения и просмотра видео я понял, что такое поведение нормально.

Некоторые варианты поддерживают режим частичного обновления (Partial Refresh Mode), в том числе 1,54-дюймовый модуль, который мы используем. При частичном обновлении экран e-paper обновляется без заметного мерцания, характерного для полного обновления.

Тем не менее настоятельно рекомендуется выполнять полное обновление после нескольких частичных. Если этого не делать, могут появиться эффекты «призраков» (ghosting), а со временем экран может выйти из строя.

Меры предосторожности

В отличие от других модулей дисплеев, модуль e-Ink довольно капризен. Я долго мучился с ним, пока разбирался. Работать с ним нужно внимательно.

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

Схема подключения

Распиновка e-Paper дисплея

распиновка e-ink
  • VCC: питание (3,3 В или 5 В) для дисплея.
  • GND: земля
  • DIN (MOSI): линия данных SPI для отправки информации от микроконтроллера на дисплей.
  • CLK (SCK): синхронизирует передачу данных по SPI.
  • CS (Chip Select, активный низкий уровень): включает или отключает обмен по SPI. При низком уровне дисплей активен.
  • DC (Data/Command Select): определяет, что передаётся: данные пикселей (высокий уровень) или команда (низкий уровень).
  • RST (Reset, активный низкий уровень): сбрасывает дисплей при подаче низкого уровня, а затем высокого. Необходим для инициализации.
  • BUSY: показывает, занят ли дисплей обработкой (высокий уровень) или готов принимать новые команды (низкий уровень).

Подключение e-Paper дисплея к ESP32

распиновка e-ink
Вывод e-Paper Провод Вывод ESP32
VCC
3.3V
GND
GND
DIN (MOSI)
GPIO23
CLK (SCK)
GPIO18
CS
GPIO33
DC
GPIO17 (TX2 на DevKit)
RST
GPIO16 (Rx2 на DevKit)
BUSY
GPIO22

Вывод текста на e-Paper (e-ink) дисплей с помощью ESP32

Давайте создадим простую программу, которая выводит текст на дисплей с помощью крейта epd-waveshare. Однако на момент написания этот крейт работал не так, как ожидалось, с 1,54-дюймовым дисплеем V2. Чтобы решить проблему, мне пришлось применить несколько патчей, поэтому пока мы будем использовать наш форк. Я ещё не отправил pull request, потому что нужны дополнительные доработки (частичные обновления работают не так, как ожидается). Тем не менее для наших упражнений форк подойдёт. Как только проблемы будут решены, эта глава будет обновлена и станет использовать последнюю исправленную версию.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 e-ink-hello

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.»
  • Выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем cargo.toml

epd-waveshare = { features = [
  "graphics",
], git = "https://github.com/ImplFerris/epd-waveshare", branch = "1in54_v2_fix" }
embedded-hal-bus = { version = "0.3" }
embedded-graphics = "0.8.1"

К этому моменту вы должны быть знакомы с крейтами embedded-hal-bus и embedded-graphics. Крейт embedded-hal предоставляет стандартизированные интерфейсы (например, SPI и I2C) для периферии микроконтроллеров, позволяя разработчикам писать переиспользуемые драйверы, которые работают на любом совместимом железе. По сути, мы будем использовать его, чтобы превратить SpiBus, предоставляемый esp-hal, в SpiDevice, который требуется драйверу epd-waveshare.

Крейт embedded-graphics мы будем использовать для отрисовки текста, фигур и изображений на дисплее.

Настройка SPI

Инициализируем SPI-устройство для связи между ESP32 и e-paper дисплеем. Это стандартная настройка: сначала инициализируем шину SPI, а затем преобразуем её в SPI-устройство с помощью embedded-hal-bus.

#![allow(unused)]
fn main() {
// Инициализируем SPI
let spi = Spi::new(
    peripherals.SPI2,
    SpiConfig::default()
        .with_frequency(Rate::from_mhz(4))
        .with_mode(SpiMode::_0),
)
.unwrap()
//CLK
.with_sck(peripherals.GPIO18)
//DIN
.with_mosi(peripherals.GPIO23);
let cs = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
let mut spi_dev = ExclusiveDevice::new(spi, cs, Delay).unwrap();
}

Инициализация e-paper дисплея

Теперь инициализируем e-paper дисплей, передав SPI-устройство и соответствующие выводы GPIO для управления.

#![allow(unused)]
fn main() {
// Инициализируем дисплей
let busy_in = Input::new(
    peripherals.GPIO22,
    InputConfig::default().with_pull(Pull::None),
);
let dc = Output::new(peripherals.GPIO17, Level::Low, OutputConfig::default());
let reset = Output::new(peripherals.GPIO16, Level::Low, OutputConfig::default());
let mut display = Display1in54::default();
let mut epd = Epd1in54::new(&mut spi_dev, busy_in, dc, reset, &mut Delay, None).unwrap();
}

Очистка дисплея

Сначала очищаем внутренний буфер e-paper дисплея, затем заполняем экран белым цветом. После этого обновляем экран, чтобы изменения отобразились. Наконец, добавляем короткую задержку, чтобы дисплей успел стабилизироваться.

#![allow(unused)]
fn main() {
epd.clear_frame(&mut spi_dev, &mut Delay).unwrap();
display.clear(Color::White).unwrap();
epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
    .unwrap();
Timer::after(Duration::from_secs(5)).await;
}

Вывод текста

Теперь наконец выведем текст «impl Rust для ESP32» на экран в позицию (x=3, y=100). После этого обновим и перерисуем дисплей, чтобы показать текст. Также добавляем короткую задержку, чтобы изменения успели примениться.

#![allow(unused)]
fn main() {
draw_text(&mut display, "impl Rust для ESP32", 3, 100);
epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
    .unwrap();
Timer::after(Duration::from_secs(5)).await;
}

Мы используем вспомогательную функцию draw_text, которая применяет API Text из крейта embedded_graphics, чтобы записать текст заданного размера и чёрным цветом в буфер дисплея.

#![allow(unused)]
fn main() {
fn draw_text(display: &mut Display1in54, text: &str, x: i32, y: i32) {
    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_10X20)
        .text_color(Color::Black)
        .build();

    Text::with_baseline(text, Point::new(x, y), text_style, Baseline::Top)
        .draw(display)
        .unwrap();
}
}

Время сна

Одна из важных мер предосторожности при использовании модуля e-Ink: после работы нужно либо перевести его в спящий режим, либо полностью отключить питание. Иначе вы рискуете повредить дисплей.

#![allow(unused)]
fn main() {
epd.sleep(&mut spi_dev, &mut Delay).unwrap();
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку e-ink-hello.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/e-ink-hello/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Delay, Duration, Timer};
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// epd
use epd_waveshare::color::Color;
use epd_waveshare::epd1in54_v2::{Display1in54, Epd1in54};
use epd_waveshare::prelude::WaveshareDisplay;

// embedded graphics
use embedded_graphics::mono_font::MonoTextStyleBuilder;
use embedded_graphics::mono_font::ascii::FONT_10X20;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(4))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);

    let cs = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
    let mut spi_dev = ExclusiveDevice::new(spi_bus, cs, Delay).unwrap();

    // Инициализируем дисплей
    let busy_in = Input::new(
        peripherals.GPIO22,
        InputConfig::default().with_pull(Pull::None),
    );
    let dc = Output::new(peripherals.GPIO17, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO16, Level::Low, OutputConfig::default());
    let mut display = Display1in54::default();
    let mut epd = Epd1in54::new(&mut spi_dev, busy_in, dc, reset, &mut Delay, None).unwrap();

    // Очищаем имеющееся изображение
    epd.clear_frame(&mut spi_dev, &mut Delay).unwrap();
    display.clear(Color::White).unwrap();
    epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    draw_text(&mut display, "impl Rust для ESP32", 3, 100);
    epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    epd.sleep(&mut spi_dev, &mut Delay).unwrap();

    loop {
        info!("Hello world!");
        Timer::after(Duration::from_secs(1)).await;
    }
}

fn draw_text(display: &mut Display1in54, text: &str, x: i32, y: i32) {
    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_10X20)
        .text_color(Color::Black)
        .build();

    Text::with_baseline(text, Point::new(x, y), text_style, Baseline::Top)
        .draw(display)
        .unwrap();
}

Вывод изображения или фигуры на e-Paper (e-ink) дисплей с ESP32

Давайте поднимем уровень ещё выше: выведем на e-Paper дисплей изображение и фигуру. Для этого можно использовать крейт embedded-graphics и, по желанию, крейт tinybmp.

Крейт tinybmp мы уже рассматривали в разделе про OLED. Он позволяет загрузить BMP-файл и вывести его на экран. Кроме того, тот же результат можно получить, используя сырой байтовый массив изображения.

Крейт требует, чтобы изображение было в формате BMP. Если ваше изображение в другом формате, его нужно конвертировать в BMP. Например, в Linux можно использовать такую команду, чтобы превратить PNG в монохромный BMP:

convert ferris.png -monochrome ferris.bmp

Я подготовил файл Ferris в формате BMP, который можно использовать для этого упражнения. Скачать его можно здесь.

файл ferris.bmp

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 e-ink-image

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.»
  • Выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Обновляем cargo.toml

epd-waveshare = { features = [
  "graphics",
], git = "https://github.com/ImplFerris/epd-waveshare", branch = "1in54_v2_fix" }
embedded-hal-bus = { version = "0.1" }
embedded-graphics = "0.8.1"
tinybmp = "0.6.0"

Подробности остальных крейтов, а также базовую настройку SPI и кода дисплея мы уже разобрали, поэтому повторяться не будем. Сразу перейдём к выводу изображения и фигуры после очистки экрана.

Чёрный фон

Вместо белого фона мы зальём фон чёрным цветом. Я выбрал чёрный, потому что у изображения, с которым мы работаем, тёмный фон, а главный объект состоит из белого цвета. Так картинка будет выглядеть лучше.

#![allow(unused)]
fn main() {
// Очищаем имеющееся изображение
epd.clear_frame(&mut spi_dev, &mut Delay).unwrap();
display.clear(Color::Black).unwrap();
epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
    .unwrap();
Timer::after(Duration::from_secs(5)).await;
}

Вывод изображения

Поместите файл ferris.bmp в папку src. Код довольно простой: загрузите изображение как байты и передайте их в функцию from_slice у Bmp. Затем можно использовать результат с Image.

#![allow(unused)]
fn main() {
let bmp_data = include_bytes!("../ferris.bmp");
let bmp = Bmp::from_slice(bmp_data).unwrap();
let image = Image::new(&bmp, Point::new(25, 60));
image.draw(&mut display).unwrap();

epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
    .unwrap();
Timer::after(Duration::from_secs(5)).await;
}

Вывод фигуры

Нарисуем окружность на дисплее.

#![allow(unused)]
fn main() {
// Выводим окружность
Circle::new(Point::new(80, 10), 40)
    .into_styled(PrimitiveStyle::with_stroke(Color::White, 2))
    .draw(&mut display)
    .ok();

epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
    .unwrap();
Timer::after(Duration::from_secs(5)).await;
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку e-ink-image.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/e-ink-image/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embassy_executor::Spawner;
use embassy_time::{Delay, Duration, Timer};
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::clock::CpuClock;
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
use esp_hal::timer::timg::TimerGroup;
use esp_println as _;

// SPI
use esp_hal::spi;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate;

// epd
use epd_waveshare::color::Color;
use epd_waveshare::epd1in54_v2::{Display1in54, Epd1in54};
use epd_waveshare::prelude::WaveshareDisplay;

// embedded-graphics
use embedded_graphics::image::Image;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{Circle, PrimitiveStyle};

// Bmp
use tinybmp::Bmp;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let _ = spawner;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(4))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);

    let cs = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
    let mut spi_dev = ExclusiveDevice::new(spi_bus, cs, Delay).unwrap();

    // Инициализируем дисплей
    let busy_in = Input::new(
        peripherals.GPIO22,
        InputConfig::default().with_pull(Pull::None),
    );
    let dc = Output::new(peripherals.GPIO17, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO16, Level::Low, OutputConfig::default());
    let mut display = Display1in54::default();
    let mut epd = Epd1in54::new(&mut spi_dev, busy_in, dc, reset, &mut Delay, None).unwrap();

    // Очищаем имеющееся изображение
    epd.clear_frame(&mut spi_dev, &mut Delay).unwrap();
    display.clear(Color::Black).unwrap();
    epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    // Вывод изображения
    let bmp_data = include_bytes!("../ferris.bmp");
    let bmp = Bmp::from_slice(bmp_data).unwrap();
    let image = Image::new(&bmp, Point::new(25, 60));
    image.draw(&mut display).unwrap();
    epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    // Выводим окружность
    Circle::new(Point::new(80, 10), 40)
        .into_styled(PrimitiveStyle::with_stroke(Color::White, 2))
        .draw(&mut display)
        .ok();
    epd.update_and_display_frame(&mut spi_dev, display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    epd.sleep(&mut spi_dev, &mut Delay).unwrap();

    loop {
        info!("Hello world!");
        Timer::after(Duration::from_secs(60)).await;
    }
}

Пишем код на Rust для погодной панели на e-Paper с ESP32

Давайте сделаем с e-ink дисплеем что-нибудь поинтереснее, чем вывод статичного текста или изображений. Мы создадим простую метеостанцию, которая получает актуальные данные о погоде из интернета по HTTP API и обновляет дисплей свежей информацией. Это что-то вроде программы «Hello, World» для e-paper.

Предварительные требования

Мы будем использовать API сайта «openweathermap.org», чтобы получать данные о погоде. Сайт выдаёт бесплатный API-ключ с лимитом 1000 запросов в день, этого должно хватить. Зайдите на сайт, зарегистрируйте бесплатный аккаунт, и вы получите собственный API-ключ.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 weather-station

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.»
  • Выберите опцию «Enable allocations via the esp-alloc crate.»
  • Затем можно включить «Enable Wi-Fi via esp-radio crate.»
  • Выберите опцию «Adds embassy framework support».

Сохраните настройки, нажав на клавиатуре «s».

Зависимости

Обновите embassy-net и добавьте feature «dns»:

embassy-net = { version = "0.7.1", features = [
  "defmt",
  "dhcpv4",
  "medium-ethernet",
  "tcp",
  "udp",
  "dns",
] }

Дополнительные зависимости:

Вкратце расскажу о новых зависимостях. Крейты embedded-graphics и tinybmp мы уже использовали много раз, в том числе в прошлой главе, так что вы должны быть с ними хорошо знакомы. Мы будем использовать их, чтобы рисовать фигуры и загружать BMP-файлы.

embedded-graphics = "0.8.1"
tinybmp = "0.6.0"
profont = "0.7.0"
chrono = { version = "0.4.40", default-features = false, features = ["serde"] }
serde = { version = "1.0.219", default-features = false, features = ["derive"] }
serde-json-core = "0.6.0"
serde_repr = "0.1.20"

epd-waveshare = { features = [
  "graphics",
], git = "https://github.com/ImplFerris/epd-waveshare" }

embedded-hal-bus = { version = "0.3" }

heapless = { version = "0.9.2", features = ["serde"] }

reqwless = { version = "0.13.0", default-features = false, features = [
  "embedded-tls",
  "alloc",
] }
  • profont: крейт предоставляет моноширинный программистский шрифт ProFont для embedded-graphics. Мы используем его, потому что для погодной панели нужен шрифт немного большего размера.
  • chrono: для работы с датой и временем. Мы будем использовать его, чтобы десериализовать текущую дату и время, полученные из API.
  • serde: фреймворк для сериализации и десериализации структур данных Rust.
  • serde_json_core: для десериализации JSON в структуру Rust мы используем этот крейт. Он создан специально для окружений no_std, а обычно вместо него применяют serde_json.
  • serde_repr: крейт позволяет сериализовать и десериализовать перечисления по их числовым значениям, что упрощает сопоставление числовых кодов погодных условий из API (например, 200 или 201) с вариантами перечисления Rust.
  • reqwless: мы уже использовали этот крейт — это HTTP-клиент, который работает в окружении no_std. С его помощью будем отправлять запросы к API и получать ответы.
  • epd-waveshare: простой драйвер для e-Ink дисплеев Waveshare по SPI. Оригинальный крейт не работал должным образом с 1,54-дюймовым вариантом, поэтому мне пришлось сделать форк и пропатчить код. Это скорее обходное решение, чем полноценное исправление, поэтому я пока не отправлял PR в оригинальный репозиторий. Пока будем использовать форк.

Структура проекта

Это общая структура проекта, и мы пройдём её по шагам.

├── build.rs
├── src
│   ├── bin
│   │   └── main.rs
│   ├── ca_cert.pem
│   ├── dashboard.rs
│   ├── icons
│   │   ├── air.bmp
│   │   ├── ...bmp
│   │   └── ...bmp
│   ├── icons.rs
│   ├── lib.rs
│   ├── weather.rs
│   └── wifi.rs
├── ...

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

Модуль lib

В lib.rs мы объявляем подмодули вместе с вспомогательным макросом, который использовали в разделе о Wi-Fi. Этот макрос резервирует память на этапе компиляции для значения static, но позволяет инициализировать его во время выполнения.

#![allow(unused)]
#![no_std]
fn main() {
pub mod dashboard;
pub mod icons;
pub mod weather;
pub mod wifi;

#[macro_export]
macro_rules! mk_static {
    ($t:ty,$val:expr) => {{
        static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
        #[deny(unused_attributes)]
        let x = STATIC_CELL.uninit().write(($val));
        x
    }};
}
}

Иконки

Нам понадобится набор иконок, чтобы показывать погоду, влажность, качество воздуха и другую информацию. Я преобразовал эти иконки из Google Fonts в файлы BMP, которые можно использовать с крейтом tinybmp.

Мы будем хранить байты изображений в статическом массиве, чтобы перебирать их и находить иконку, которая соответствует текущей погоде. Для этого я добавил в build.rs новую функцию: она загружает файлы иконок из указанной папки и генерирует статический массив в icons.rs. Так нам не нужно вручную обновлять массив каждый раз, когда добавляется новая иконка.

Изменяем файл build.rs

В build.rs мы добавим функцию, которая сканирует папку src/icons/. Иконки можно взять из этого репозитория: https://github.com/ImplFerris/esp32-epaper-weather/tree/main/src/icons и поместить в свою папку src/icons.

#![allow(unused)]
fn main() {
fn generate_icon_array() {
    let dest_path = Path::new("src/icons.rs");
    let icon_dir = Path::new("src/icons/");

    let mut generated_code = String::new();

    generated_code.push_str("pub static ICONS: &[(&str, &[u8])] = &[\n");

    if let Ok(entries) = fs::read_dir(icon_dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) {
                generated_code.push_str(&format!(
                    "    (\"{}\", include_bytes!(\"{}/{}\")),\n",
                    file_name, "icons", file_name
                ));
            }
        }
    }

    generated_code.push_str("];\n");

    fs::write(dest_path, generated_code).expect("Failed to write icons.rs");

    println!("cargo:rerun-if-changed={}", icon_dir.to_str().unwrap());
}
}

Обновите функцию main в build.rs, чтобы она вызывала нашу новую функцию:

fn main() {
    generate_icon_array();

    linker_be_nice();
    println!("cargo:rustc-link-arg=-Tdefmt.x");
    // убедитесь, что linkall.x — последний скрипт компоновщика (иначе возможны проблемы с flip-link)
    println!("cargo:rustc-link-arg=-Tlinkall.x");
}

icons.rs — не меняйте вручную!

Если всё прошло гладко, в папке src появится файл icons.rs с содержимым ниже. Если файл не создан, попробуйте запустить скрипт сборки вручную, выполнив cargo build.

#![allow(unused)]
fn main() {
pub static ICONS: &[(&str, &[u8])] = &[
    ("partly_cloudy_day.bmp", include_bytes!("icons/partly_cloudy_day.bmp")),
    ("nights_stay.bmp", include_bytes!("icons/nights_stay.bmp")),
    ("humidity_percentage.bmp", include_bytes!("icons/humidity_percentage.bmp")),
    ("cyclone.bmp", include_bytes!("icons/cyclone.bmp")),
    ("foggy.bmp", include_bytes!("icons/foggy.bmp")),
    ("snowing_heavy.bmp", include_bytes!("icons/snowing_heavy.bmp")),
    ("sunny.bmp", include_bytes!("icons/sunny.bmp")),
    ("cloud.bmp", include_bytes!("icons/cloud.bmp")),
    ("partly_cloudy_night.bmp", include_bytes!("icons/partly_cloudy_night.bmp")),
    ("rainy.bmp", include_bytes!("icons/rainy.bmp")),
    ("heat.bmp", include_bytes!("icons/heat.bmp")),
    ("clear_day.bmp", include_bytes!("icons/clear_day.bmp")),
    ("weather_mix.bmp", include_bytes!("icons/weather_mix.bmp")),
    ("weather_hail.bmp", include_bytes!("icons/weather_hail.bmp")),
    ("rainy_snow.bmp", include_bytes!("icons/rainy_snow.bmp")),
    ("mist.bmp", include_bytes!("icons/mist.bmp")),
    ("snowing.bmp", include_bytes!("icons/snowing.bmp")),
    ("rainy_heavy.bmp", include_bytes!("icons/rainy_heavy.bmp")),
    ("flood.bmp", include_bytes!("icons/flood.bmp")),
    ("sunny_snowing.bmp", include_bytes!("icons/sunny_snowing.bmp")),
    ("air.bmp", include_bytes!("icons/air.bmp")),
    ("storm.bmp", include_bytes!("icons/storm.bmp")),
    ("weather_snowy.bmp", include_bytes!("icons/weather_snowy.bmp")),
    ("thunderstorm.bmp", include_bytes!("icons/thunderstorm.bmp")),
    ("thermostat.bmp", include_bytes!("icons/thermostat.bmp")),
    ("rainy_light.bmp", include_bytes!("icons/rainy_light.bmp")),
];
}

Модуль Wi-Fi

Код wifi.rs такой же, как в главе «Доступ к сайту» раздела Wi-Fi. Подробнее рекомендую посмотреть там.

#![allow(unused)]
fn main() {
use crate::mk_static;
use embassy_executor::Spawner;
use embassy_net::{DhcpConfig, Runner, Stack, StackResources};
use embassy_time::{Duration, Timer};
use esp_hal::rng::Rng;
use esp_println as _;
use esp_println::println;
use esp_radio::wifi::{
    ClientConfig, ModeConfig, ScanConfig, WifiController, WifiDevice, WifiEvent, WifiStaState,
};

const SSID: &str = env!("SSID");
const PASSWORD: &str = env!("PASSWORD");

#[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) {
    println!("start connection task");
    println!("Device capabilities: {:?}", controller.capabilities());
    loop {
        match esp_radio::wifi::sta_state() {
            WifiStaState::Connected => {
                // ждём, пока соединение не будет разорвано
                controller.wait_for_event(WifiEvent::StaDisconnected).await;
                Timer::after(Duration::from_millis(5000)).await
            }
            _ => {}
        }
        if !matches!(controller.is_started(), Ok(true)) {
            let client_config = ModeConfig::Client(
                ClientConfig::default()
                    .with_ssid(SSID.into())
                    .with_password(PASSWORD.into()),
            );
            controller.set_config(&client_config).unwrap();
            println!("Starting wifi");
            controller.start_async().await.unwrap();
            println!("Wifi started!");

            println!("Scan");
            let scan_config = ScanConfig::default().with_max(10);
            let result = controller
                .scan_with_config_async(scan_config)
                .await
                .unwrap();
            for ap in result {
                println!("{:?}", ap);
            }
        }
        println!("About to connect...");

        match controller.connect_async().await {
            Ok(_) => println!("Wifi connected!"),
            Err(e) => {
                println!("Failed to connect to wifi: {:?}", e);
                Timer::after(Duration::from_millis(5000)).await
            }
        }
    }
}

#[embassy_executor::task]
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
    runner.run().await
}

pub async fn start_wifi(
    radio_init: &'static esp_radio::Controller<'static>,
    wifi: esp_hal::peripherals::WIFI<'static>,
    rng: Rng,
    spawner: &Spawner,
) -> Stack<'static> {
    let (wifi_controller, interfaces) = esp_radio::wifi::new(radio_init, wifi, Default::default())
        .expect("Failed to initialize Wi-Fi controller");

    let wifi_interface = interfaces.sta;
    let net_seed = rng.random() as u64 | ((rng.random() as u64) << 32);

    let dhcp_config = DhcpConfig::default();
    let net_config = embassy_net::Config::dhcpv4(dhcp_config);

    // Инициализируем сетевой стек
    let (stack, runner) = embassy_net::new(
        wifi_interface,
        net_config,
        mk_static!(StackResources<3>, StackResources::<3>::new()),
        net_seed,
    );

    spawner.spawn(connection(wifi_controller)).ok();
    spawner.spawn(net_task(runner)).ok();

    wait_for_connection(stack).await;

    stack
}

async fn wait_for_connection(stack: Stack<'_>) {
    println!("Waiting for link to be up");
    loop {
        if stack.is_link_up() {
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }

    println!("Waiting to get IP address...");
    loop {
        if let Some(config) = stack.config_v4() {
            println!("Got IP: {}", config.address);
            break;
        }
        Timer::after(Duration::from_millis(500)).await;
    }
}
}

Погодное API

В этой главе мы научимся отправлять запрос к API и десериализовать JSON-ответ в структуру.

Надеюсь, вы получили API-ключ на сайте openweathermap. Его можно передать через переменную окружения, поэтому определим константу так:

#![allow(unused)]
fn main() {
const API_KEY: &str = env!("API_KEY");
}

Из JSON в структуру

Советую изучить документацию API по адресу «https://openweathermap.org/current» и познакомиться со структурой JSON-ответа. На верхнем уровне я определил структуру WeatherData с полями weather, main и wind, каждое из которых использует собственные типы структур. Чтобы не загромождать руководство, определения остальных структур приведены в конце главы.

#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize)]
pub struct WeatherData {
    pub weather: Vec<Weather, 4>,
    pub main: Main,
    pub wind: Wind,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub dt: DateTime<Utc>,
    pub name: String<20>,
}
}

Здесь мы десериализуем только нужные поля, а остальные данные JSON игнорируем.

Погодный API

Создадим структуру-обёртку для обработки запросов к API. Хотя можно обойтись и простой функцией, тогда пришлось бы каждый раз передавать URL и Wi-Fi-стек. Используя структуру, мы создаём её один раз и вызываем метод access_website всякий раз, когда нужны свежие данные о погоде.

#![allow(unused)]
fn main() {
pub struct WeatherApi {
    wifi: Stack<'static>,
    url: String<120>,
    tls_seed: u64,
}
}

В функции new мы формируем URL API, добавляя к нему API-ключ, и сохраняем его в поле.

ПРИМЕЧАНИЕ: как вы могли заметить, я использовал «London» в качестве названия города. Вы можете заменить его на название своего города с кодом страны. Подробности смотрите в документации и при необходимости внесите изменения.

#![allow(unused)]
fn main() {
pub fn new(wifi: Stack<'static>, tls_seed: u64) -> Self {
    let mut url = String::new();
    url.push_str(
        "https://api.openweathermap.org/data/2.5/weather?q=London&units=metric&appid=",
    )
    .unwrap();
    url.push_str(API_KEY).unwrap();
    Self {
        wifi,
        url,
        tls_seed,
    }
}
}

Отправка запроса к API

Далее создаём DNS-сокет и TCP-клиент. Когда базовая настройка готова, передаём TCP-клиент, DNS-сокет и конфигурацию TLS в HttpClient и с его помощью отправляем запрос по URL API. Получив ответ, десериализуем JSON-полезную нагрузку в структуру WeatherData с помощью serde_json_core.

#![allow(unused)]
fn main() {
pub async fn access_website(&self) -> WeatherData {
    let mut rx_buffer = [0; 4096 * 2];
    let mut tx_buffer = [0; 4096 * 2];

    let tls_config = TlsConfig::new(
        self.tls_seed,
        &mut rx_buffer,
        &mut tx_buffer,
        reqwless::client::TlsVerify::None,
    );

    let dns = DnsSocket::new(self.wifi);
    let tcp_state = TcpClientState::<1, 4096, 4096>::new();
    let tcp = TcpClient::new(self.wifi, &tcp_state);

    let mut client = HttpClient::new_with_tls(&tcp, &dns, tls_config);
    let mut buffer = [0u8; 4096];
    let mut http_req = client
        .request(reqwless::request::Method::GET, &self.url)
        .await
        .unwrap();
    let response = http_req.send(&mut buffer).await.unwrap();

    info!("Got response");
    let res = response.body().read_to_end().await.unwrap();

    let (data, _): (WeatherData, _) = serde_json_core::de::from_slice(res).unwrap();
    data
}
}

Остальная часть структуры

Обратите внимание: API возвращает состояние погоды числом (он также даёт строку, но с числом работать проще). Мы сопоставим эти числа с перечислением ConditionCode. Кроме того, в это перечисление мы добавили функцию icon, которая сообщает, какую иконку показывать для каждого конкретного погодного условия.

#![allow(unused)]
fn main() {
#[derive(Debug, Deserialize_repr)]
#[repr(u16)]
pub enum ConditionCode {
    // Группа 2xx: гроза
    ThunderstormWithLightRain = 200,
    ThunderstormWithRain = 201,
    ThunderstormWithHeavyRain = 202,
    LightThunderstorm = 210,
    Thunderstorm = 211,
    HeavyThunderstorm = 212,
    RaggedThunderstorm = 221,
    ThunderstormWithLightDrizzle = 230,
    ThunderstormWithDrizzle = 231,
    ThunderstormWithHeavyDrizzle = 232,

    // Группа 3xx: морось
    LightIntensityDrizzle = 300,
    Drizzle = 301,
    HeavyIntensityDrizzle = 302,
    LightIntensityDrizzleRain = 310,
    DrizzleRain = 311,
    HeavyIntensityDrizzleRain = 312,
    ShowerRainAndDrizzle = 313,
    HeavyShowerRainAndDrizzle = 314,
    ShowerDrizzle = 321,

    // Группа 5xx: дождь
    LightRain = 500,
    ModerateRain = 501,
    HeavyIntensityRain = 502,
    VeryHeavyRain = 503,
    ExtremeRain = 504,
    FreezingRain = 511,
    LightIntensityShowerRain = 520,
    ShowerRain = 521,
    HeavyIntensityShowerRain = 522,
    RaggedShowerRain = 531,

    // Группа 6xx: снег
    LightSnow = 600,
    Snow = 601,
    HeavySnow = 602,
    Sleet = 611,
    LightShowerSleet = 612,
    ShowerSleet = 613,
    LightRainAndSnow = 615,
    RainAndSnow = 616,
    LightShowerSnow = 620,
    ShowerSnow = 621,
    HeavyShowerSnow = 622,

    // Группа 7xx: атмосфера
    Mist = 701,
    Smoke = 711,
    Haze = 721,
    SandDustWhirls = 731,
    Fog = 741,
    Sand = 751,
    Dust = 761,
    VolcanicAsh = 762,
    Squalls = 771,
    Tornado = 781,

    // Группа 800: ясно
    ClearSky = 800,

    // Группа 80x: облака
    FewClouds = 801,
    ScatteredClouds = 802,
    BrokenClouds = 803,
    OvercastClouds = 804,
}

impl ConditionCode {
    pub fn icon(&self) -> &'static str {
        match self {
            // Гроза
            ConditionCode::ThunderstormWithLightRain
            | ConditionCode::ThunderstormWithRain
            | ConditionCode::ThunderstormWithHeavyRain
            | ConditionCode::LightThunderstorm
            | ConditionCode::Thunderstorm
            | ConditionCode::HeavyThunderstorm
            | ConditionCode::RaggedThunderstorm
            | ConditionCode::ThunderstormWithLightDrizzle
            | ConditionCode::ThunderstormWithDrizzle
            | ConditionCode::ThunderstormWithHeavyDrizzle => "storm.bmp",

            // Морось
            ConditionCode::LightIntensityDrizzle
            | ConditionCode::Drizzle
            | ConditionCode::HeavyIntensityDrizzle
            | ConditionCode::LightIntensityDrizzleRain
            | ConditionCode::DrizzleRain
            | ConditionCode::HeavyIntensityDrizzleRain
            | ConditionCode::ShowerRainAndDrizzle
            | ConditionCode::HeavyShowerRainAndDrizzle
            | ConditionCode::ShowerDrizzle => "rainy.bmp",

            // Дождь
            ConditionCode::LightRain
            | ConditionCode::ModerateRain
            | ConditionCode::HeavyIntensityRain
            | ConditionCode::VeryHeavyRain
            | ConditionCode::ExtremeRain
            | ConditionCode::LightIntensityShowerRain
            | ConditionCode::ShowerRain
            | ConditionCode::HeavyIntensityShowerRain
            | ConditionCode::RaggedShowerRain => "rainy_heavy.bmp",
            ConditionCode::FreezingRain => "weather_mix.bmp",

            // Снег
            ConditionCode::LightSnow
            | ConditionCode::Snow
            | ConditionCode::HeavySnow
            | ConditionCode::Sleet
            | ConditionCode::LightShowerSleet
            | ConditionCode::ShowerSleet
            | ConditionCode::LightRainAndSnow
            | ConditionCode::RainAndSnow
            | ConditionCode::LightShowerSnow
            | ConditionCode::ShowerSnow
            | ConditionCode::HeavyShowerSnow => "snowing.bmp",

            // Атмосфера
            ConditionCode::Mist
            | ConditionCode::Smoke
            | ConditionCode::Haze
            | ConditionCode::SandDustWhirls
            | ConditionCode::Fog
            | ConditionCode::Sand
            | ConditionCode::Dust
            | ConditionCode::VolcanicAsh
            | ConditionCode::Squalls => "foggy.bmp",
            ConditionCode::Tornado => "cyclone.bmp",

            // Ясно
            ConditionCode::ClearSky => "sunny.bmp",

            // Облака
            ConditionCode::FewClouds
            | ConditionCode::ScatteredClouds
            | ConditionCode::BrokenClouds
            | ConditionCode::OvercastClouds => "partly_cloudy_day.bmp",
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct Weather {
    pub id: ConditionCode,
}

#[derive(Debug, Deserialize)]
pub struct Main {
    pub temp: f64,
    pub feels_like: f64,
    pub temp_min: f64,
    pub temp_max: f64,
    pub pressure: i32,
    pub humidity: i32,
    pub sea_level: Option<i32>,
    pub grnd_level: Option<i32>,
}

#[derive(Debug, Deserialize)]
pub struct Wind {
    pub speed: f64,
    pub deg: f64,
    pub gust: Option<f64>,
}

}

Панель (Dashboard)

В этой главе мы напишем основную логику. Идея в том, чтобы обновлять панель через определённые промежутки времени, скажем каждые 10 минут. Для этого мы будем получать свежие данные о погоде, вызывая функцию access_website из модуля weather, и затем обновлять каждую часть панели новой информацией.

Псевдонимы типов

Создадим псевдонимы типов для SPI-устройства и e-paper дисплея, чтобы код было легче читать и использовать.

#![allow(unused)]
fn main() {
type SpiDevice = ExclusiveDevice<Spi<'static, esp_hal::Blocking>, Output<'static>, Delay>;
type EPD = Epd1in54<SpiDevice, Input<'static>, Output<'static>, Output<'static>, Delay>;
}

Структура Dashboard

Определим структуру Dashboard, которая принимает Wi-Fi-стек, драйвер e-paper и SPI-устройство. Функция new инициализирует структуру и задаёт дисплей e-paper по умолчанию.

#![allow(unused)]
fn main() {
pub struct Dashboard {
    display: Display1in54,
    wifi: Stack<'static>,
    epd: EPD,
    spi_dev: SpiDevice,
}

impl Dashboard {
    pub fn new(wifi: Stack<'static>, epd: EPD, spi_dev: SpiDevice) -> Self {
        Self {
            display: Display1in54::default(),
            wifi,
            epd,
            spi_dev,
        }
    }
}
}

Примечание: следующие функции, которые принимают self первым параметром, тоже должны находиться внутри блока impl.

Запуск панели

Это стартовая функция, которую мы вызовем далее в файле main.rs. Здесь же создаём экземпляр WeatherApi.

Я повернул дисплей на 90 градусов. Технически в этом нет необходимости, так как наш дисплей имеет размер 200x200 пикселей. Но если вы используете прямоугольный дисплей, поворот имеет больше смысла.

В цикле каждые 10 минут мы вызываем функцию refresh, которая получает свежие данные и обновляет дисплей.

#![allow(unused)]
fn main() {
pub async fn start(&mut self, tls_seed: u64) {
    self.display.set_rotation(DisplayRotation::Rotate90);

    let api = WeatherApi::new(self.wifi, tls_seed);
    loop {
        self.refresh(&api).await;

        Timer::after(Duration::from_secs(60 * 10)).await;
    }
}
}

Обновление дисплея свежими данными о погоде

Сначала получаем свежие данные о погоде, вызывая access_website у WeatherApi. Затем выводим e-paper дисплей из режима сна, так как в конце мы снова его усыпим. После этого очищаем предыдущий кадр и заливаем дисплей белым цветом.

Далее выводим обновлённую информацию о погоде: сначала дату, затем иконку погоды и температуру. После этого добавляем влажность, скорость ветра и, наконец, подпись внизу (небольшой текст «implRust»). Когда всё нарисовано, обновляем дисплей новым кадром, ждём 5 секунд и переводим дисплей в спящий режим.

#![allow(unused)]
fn main() {
pub async fn refresh(&mut self, api: &WeatherApi) {
    info!("Getting weather data");
    let weather_data = api.access_website().await;
    info!("Got weather data");

    self.epd.wake_up(&mut self.spi_dev, &mut Delay).unwrap();
    Timer::after(Duration::from_secs(5)).await;

    // Очищаем имеющееся изображение
    self.epd.clear_frame(&mut self.spi_dev, &mut Delay).unwrap();
    self.display.clear(Color::White).unwrap();
    self.epd
        .update_and_display_frame(&mut self.spi_dev, self.display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    self.draw_date(weather_data.dt);

    self.draw_icon(weather_data.weather[0].id.icon(), Point::new(20, 50));
    self.draw_temperature(weather_data.main.temp, Point::new(20 + 70, 60));

    self.draw_humidity(weather_data.main.humidity);
    self.draw_wind(weather_data.wind.speed);

    self.draw_signature();

    self.epd
        .update_and_display_frame(&mut self.spi_dev, self.display.buffer(), &mut Delay)
        .unwrap();
    Timer::after(Duration::from_secs(5)).await;

    self.epd.sleep(&mut self.spi_dev, &mut Delay).unwrap();
}
}

Вспомогательная функция получения иконки

Эта простая функция принимает имя иконки и позицию, в которой её нужно нарисовать. Она сопоставляет имя иконки с соответствующими байтами изображения, затем с помощью tinybmp и embedded_graphics превращает байты в изображение и выводит его на дисплей.

#![allow(unused)]
fn main() {
fn draw_icon(&mut self, icon_name: &'static str, pos: Point) {
    let img_bytes = self.get_icon(icon_name).unwrap();

    let bmp = Bmp::from_slice(img_bytes).unwrap();
    let image = Image::new(&bmp, pos);
    image.draw(&mut self.display).unwrap();
}

pub fn get_icon(&self, icon_name: &'static str) -> Option<&'static [u8]> {
    ICONS
        .iter()
        .find(|(name, _)| *name == icon_name)
        .map(|(_, img_bytes)| *img_bytes)
}
}

Вывод температуры

Выведем температуру на экран, добавим к ней суффикс «°C» и нарисуем под ней горизонтальную линию.

#![allow(unused)]
fn main() {
fn draw_temperature(&mut self, temperature: f64, pos: Point) {
    let text_style = MonoTextStyle::new(&PROFONT_24_POINT, Color::Black);

    info!("Drawing temperature");
    let mut text: String<20> = String::new();
    write!(&mut text, "{}°C", temperature).unwrap();

    Text::with_baseline(&text, pos, text_style, Baseline::Top)
        .draw(&mut self.display)
        .unwrap();

    Line::new(Point::new(0, 105), Point::new(200, 105))
        .into_styled(PrimitiveStyle::with_stroke(Color::Black, 5))
        .draw(&mut self.display)
        .unwrap();
}
}

Вывод влажности

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

#![allow(unused)]
fn main() {
fn draw_humidity(&mut self, humidity: i32) {
    self.draw_icon("humidity_percentage.bmp", Point::new(5, 110));

    let text_style = MonoTextStyle::new(&PROFONT_18_POINT, Color::Black);

    let mut text: String<10> = String::new();
    write!(&mut text, "{}", humidity).unwrap();

    Text::with_baseline(&text, Point::new(5 + 50, 120), text_style, Baseline::Top)
        .draw(&mut self.display)
        .unwrap();

    Line::new(Point::new(5 + 85, 120), Point::new(5 + 85, 120 + 30))
        .into_styled(PrimitiveStyle::with_stroke(Color::Black, 5))
        .draw(&mut self.display)
        .unwrap();
}
}

Вывод ветра

Выведем скорость ветра на экран: сначала нарисуем иконку ветра, затем значение скорости и единицу измерения m/s (м/с).

#![allow(unused)]
fn main() {
fn draw_wind(&mut self, wind_speed: f64) {
    self.draw_icon("air.bmp", Point::new(100, 110));

    let text_style = MonoTextStyle::new(&PROFONT_18_POINT, Color::Black);

    let mut text: String<10> = String::new();
    write!(&mut text, "{}", wind_speed).unwrap();

    Text::with_baseline(&text, Point::new(100 + 50, 120), text_style, Baseline::Top)
        .draw(&mut self.display)
        .unwrap();

    let text_style = MonoTextStyleBuilder::new()
        .font(&FONT_10X20)
        .text_color(Color::Black)
        .build();

    Text::with_baseline("m/s", Point::new(100 + 50, 140), text_style, Baseline::Top)
        .draw(&mut self.display)
        .unwrap();
}
}

Вывод даты

Выведем текущую дату на экран, отформатировав её с днём, месяцем и годом. Затем выведем текст в заданной позиции и нарисуем под ним горизонтальную линию для разделения.

#![allow(unused)]
fn main() {
fn draw_date(&mut self, dt: DateTime<Utc>) {
    let text_style = MonoTextStyle::new(&PROFONT_24_POINT, Color::Black);

    let mut text: String<24> = String::new();
    write!(
        &mut text,
        "{} {} {}",
        dt.day(),
        month_name(dt.month()),
        dt.year()
    )
    .unwrap();

    Text::with_baseline(&text, Point::new(20, 10), text_style, Baseline::Top)
        .draw(&mut self.display)
        .unwrap();

    Line::new(Point::new(0, 45), Point::new(200, 45))
        .into_styled(PrimitiveStyle::with_stroke(Color::Black, 5))
        .draw(&mut self.display)
        .unwrap();
}
}

Вывод подписи

Это не связано с погодой: мы просто выводим «implRust» для удовольствия. Вычисляем центральную позицию, рисуем в центре чёрный прямоугольник и помещаем текст ровно посередине.

#![allow(unused)]
fn main() {
fn draw_signature(&mut self) {
    let display_width = epd1in54_v2::WIDTH as i32;
    let rect_padding = 20;

    let rect_width = display_width - 2 * rect_padding;
    let rect_height = 40;
    let rect_x = rect_padding;
    let rect_y = 170;

    let style = PrimitiveStyleBuilder::new()
        .stroke_color(Color::Black)
        .stroke_width(3)
        .fill_color(Color::Black)
        .build();

    Rectangle::new(
        Point::new(rect_x, rect_y),
        Size::new(rect_width as u32, rect_height as u32),
    )
    .into_styled(style)
    .draw(&mut self.display)
    .unwrap();

    let text = "implRust";
    let text_style = MonoTextStyle::new(&PROFONT_24_POINT, Color::White);

    let char_width = PROFONT_24_POINT.character_size.width as i32;
    let text_width = text.len() as i32 * char_width;
    let text_x = rect_x + (rect_width - text_width) / 2;

    Text::with_baseline(
        text,
        Point::new(text_x as i32, rect_y),
        text_style,
        Baseline::Top,
    )
    .draw(&mut self.display)
    .unwrap();
}
}

Вспомогательная функция для названия месяца

Мы создали вспомогательную функцию, которая возвращает сокращённое название месяца по его номеру.

#![allow(unused)]
fn main() {
fn month_name(month: u32) -> &'static str {
    match month {
        1 => "Jan",
        2 => "Feb",
        3 => "Mar",
        4 => "Apr",
        5 => "May",
        6 => "Jun",
        7 => "Jul",
        8 => "Aug",
        9 => "Sep",
        10 => "Oct",
        11 => "Nov",
        12 => "Dec",
        _ => "Err",
    }
}
}

Начинается самое интересное

В функции main выполним обычные шаги настройки: инициализируем стек Wi-Fi, SPI-устройство и создадим экземпляр Dashboard, который мы определили ранее.

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    let timg0 = TimerGroup::new(peripherals.TIMG0);
    esp_rtos::start(timg0.timer0);

    info!("Embassy initialized!");

    let radio_init = &*lib::mk_static!(
        esp_radio::Controller<'static>,
        esp_radio::init().expect("Failed to initialize Wi-Fi/BLE controller")
    );
    let rng = Rng::new();

    let stack = lib::wifi::start_wifi(radio_init, peripherals.WIFI, rng, &spawner).await;

    let spi_bus = Spi::new(
        peripherals.SPI2,
        spi::master::Config::default()
            .with_frequency(Rate::from_mhz(4))
            .with_mode(spi::Mode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);

    let cs = Output::new(peripherals.GPIO33, Level::Low, OutputConfig::default());
    let mut spi_dev = ExclusiveDevice::new(spi_bus, cs, Delay).unwrap();

    // Инициализируем дисплей
    let busy_in = Input::new(
        peripherals.GPIO22,
        InputConfig::default().with_pull(Pull::None),
    );
    let dc = Output::new(peripherals.GPIO17, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO16, Level::Low, OutputConfig::default());
    let epd = Epd1in54::new(&mut spi_dev, busy_in, dc, reset, &mut Delay, None).unwrap();

    let tls_seed = rng.random() as u64 | ((rng.random() as u64) << 32);

    let mut app = Dashboard::new(stack, epd, spi_dev);
    app.start(tls_seed).await;

    loop {
        Timer::after(Duration::from_secs(1)).await;
    }
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку wifi-webfetch.

git clone https://github.com/ImplFerris/esp32-epaper-weather/
cd esp32-epaper-weather

Как запустить?

При прошивке программы в ESP32 нужно передать имя Wi-Fi сети (SSID), пароль Wi-Fi и ключ API Open Weather как переменные окружения.

SSID=YOUR_WIFI_NAME PASSWORD=YOUR_WIFI_PASSWORD API_KEY=OPEN_WEATHER_KEY  cargo run --release

Если всё прошло успешно, e-paper дисплей ненадолго мигнёт, очищаясь и отрисовывая содержимое, и вы увидите данные о погоде.

метеостанция на e-paper дисплее

TFT-дисплей

В этой главе мы научимся работать с TFT-дисплеем. TFT расшифровывается как Thin-Film Transistor (тонкоплёночный транзистор). Это разновидность ЖК-дисплея (LCD), которая даёт яркие цвета, чёткие изображения и высокую скорость обновления. По сравнению с простыми текстовыми дисплеями, такими как ЖК 16x2 или монохромные OLED, TFT-дисплеи позволяют выводить полноцветную графику, фигуры, изображения и даже интерфейсы с сенсорным управлением.

Что такое TFT-дисплей?

TFT-дисплей состоит из тысяч крошечных пикселей, расположенных в виде сетки. Каждый пиксель может показывать разные цвета, благодаря чему на экране можно нарисовать что угодно. В дисплее есть микросхема-контроллер, которая понимает данные, приходящие от микроконтроллера, и соответственно обновляет экран.

TFT-дисплеи очень распространены в мобильных телефонах, смарт-часах и портативных гаджетах. Во встраиваемых системах их используют для панелей мониторинга, игр, пультов управления и многого другого.

Разновидности TFT-дисплеев

TFT-модули бывают разных размеров, разрешений и типов связи. Вот несколько распространённых различий:

  • Размер: от 1,3 дюйма до 7 дюймов и больше.

  • Разрешение: от 128×160 до 800×480 и выше.

  • Тип интерфейса: TFT-дисплеи предлагают разные способы подключения к микроконтроллеру. Интерфейс SPI прост и использует меньше выводов, но работает медленнее. Параллельный интерфейс быстрее, но требует больше выводов. У больших дисплеев часто используется интерфейс RGB (TTL), который очень быстрый, но для работы требует специального контроллера.

Наш дисплей: 2,8-дюймовый сенсорный экран с SPI

В этом проекте мы будем использовать TFT-модуль 2,8 дюйма с разрешением 240×320 пикселей. Он работает по интерфейсу SPI, благодаря чему подключается к микроконтроллеру всего несколькими выводами.

Модуль также оснащён резистивным сенсорным экраном, который позволяет определять касания, измеряя давление на экран. Сначала мы сосредоточимся на выводе изображения на дисплей.

Драйвер дисплея (контроллер)

У каждого TFT-дисплея есть микросхема-контроллер, которая отвечает за отрисовку пикселей на экране. В нашем случае дисплей использует микросхему ILI9341. Мы отправляем этой микросхеме команды, и она выполняет такие задачи, как обновление экрана, рисование фигур, заливка цветом и многое другое.

Контроллер ILI9341 чаще всего используется в 2,4- и 2,8-дюймовых TFT-дисплеях, а иногда и в 3,2-дюймовых. Если ваш экран использует ту же микросхему ILI9341, с ним будут работать те же код и примеры из этого руководства.

Также можно использовать дисплеи с другими микросхемами драйверов (например, ST7735, ILI9225, ILI9486), но для них понадобятся другие крейты Rust или иные инструкции по настройке. Поэтому важно перед покупкой проверить, какой контроллер используется в дисплее, чтобы убедиться в совместимости с руководством.

Контроллер сенсорного экрана

Некоторые TFT-дисплеи (как тот, что используем мы) также имеют резистивный сенсорный экран поверх основного. В таких дисплеях функцию касания выполняет отдельная микросхема — XPT2046.

Эта микросхема считывает координаты X и Y, когда вы касаетесь экрана, и передаёт эти данные по SPI. Она работает независимо от драйвера дисплея (ILI9341).

Даташиты

Подключение TFT-дисплея к ESP32

В этом разделе мы рассмотрим распиновку TFT-модуля и узнаем, как подключить его к ESP32. Если присмотреться, она очень похожа на распиновку модуля e-ink. Это потому, что оба дисплея используют SPI (Serial Peripheral Interface) для связи и имеют общие управляющие линии: CS, DC, RESET, MOSI и SCK. Поэтому, разобравшись, как подключить и управлять одним дисплеем на SPI, перейти к другому будет гораздо проще.

Распиновка TFT-дисплея

распиновка TFT-дисплея
  • VCC: питание (3,3 В или 5 В). Это основное питание всего дисплейного модуля. Оно питает контроллер дисплея (например, ILI9341) и другие части.
  • GND: земля
  • CS: Chip Select (выбор микросхемы). Сообщает дисплею, когда нужно слушать команды SPI. Держите низкий уровень (активный) при отправке данных.
  • RESET: сброс дисплея. Полезен при запуске, чтобы дисплей стартовал в известном состоянии.
  • DC: линия выбора данных/команды (Data/Command). Высокий уровень — отправка данных, низкий уровень — отправка команд. С её помощью переключаются между записью команд и данных пикселей.
  • SDI (MOSI): Master Out Slave In. Линия данных SPI от микроконтроллера к дисплею. По ней передаются данные пикселей и команды.
  • SCK: Serial Clock. Тактовый сигнал SPI от микроконтроллера. Он синхронизирует передаваемые данные.
  • LED: этот вывод предназначен для подсветки (светодиодной панели) дисплея. Он регулирует яркость экрана. Если нужна постоянно включённая подсветка, подключите этот вывод к 3,3 В. Если хотите регулировать яркость (например, приглушать или выключать), подключите его к выводу GPIO микроконтроллера, поддерживающему ШИМ.
  • SDO (MISO): Master In Slave Out. Линия чтения SPI от дисплея к микроконтроллеру. Используется не всегда, но некоторые дисплеи поддерживают чтение памяти или состояния дисплея.

Распиновка контроллера касания

Если в вашем TFT-дисплее есть резистивный сенсорный экран, в нём, скорее всего, используется контроллер касания, например XPT2046. Эти выводы касания отделены от выводов дисплея и тоже подключаются по SPI. Подключать их нужно только если вы хотите использовать функцию касания.

  • T_CLK: вывод тактирования шины SPI сенсорного экрана
  • T_CS: вывод выбора микросхемы сенсорного экрана
  • T_DIN: вывод записи данных SPI сенсорного экрана (MOSI)
  • T_DO: вывод чтения данных SPI сенсорного экрана (MISO)
  • T_IRQ: вывод обнаружения прерывания сенсорного экрана

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

Подключение TFT-дисплея к ESP32

TFT-дисплей с ESP32
Вывод TFT-дисплея Провод Вывод ESP32
VCC
Vin
GND
GND
CS
GPIO15
RESET
GPIO4
DC
GPIO2
SDI (MOSI)
GPIO23
SCK (CLK)
GPIO18
LED
3.3V (или PWM-вывод для регулировки яркости)

Вывод текста на TFT-дисплей с ESP32

Создадим простую программу, которая выводит текст на дисплей с помощью крейта ili9341.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 tft-display-hello

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.»

Сохраните настройки, нажав на клавиатуре «s».

Обновляем cargo.toml

embedded-hal-bus = { version = "0.3" }
display-interface-spi = "0.5"
ili9341 = "0.6.0"
embedded-graphics = "0.8.1"
profont = "0.7.0"

К этому моменту вы должны быть знакомы с крейтами embedded-hal-bus и embedded-graphics. Крейт embedded-hal предоставляет стандартизированные интерфейсы (например, SPI, I2C) для периферии микроконтроллеров, позволяя разработчикам писать переиспользуемые драйверы, которые работают на любом совместимом железе. По сути, мы будем использовать его, чтобы превратить SpiBus, предоставляемый esp-hal, в SpiDevice.

Однако в отличие от предыдущих глав, где мы использовали SpiDevice напрямую, крейт ili9341 требует ещё один уровень: он ожидает интерфейс, который реализует трейты из крейта display-interface-spi. Этот крейт определяет трейты и обёртки, которые связывают драйверы SPI-шины с драйверами дисплеев, сами обрабатывая детали, например линию данных/команд (DC).

Крейт profont мы используем, чтобы получить более крупный моноширинный шрифт для дисплея, потому что встроенные шрифты embedded-graphics слишком мелкие.

Крейт embedded-graphics мы будем использовать для отрисовки текста, фигур и изображений на дисплее.

Необходимые импорты

#![allow(unused)]
fn main() {

// Обычные импорты
use defmt::info;
use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_hal::time::{Duration, Instant};
use esp_println as _;

// Связанное с embedded-graphics
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};

// Крупный шрифт
use profont::{PROFONT_18_POINT, PROFONT_24_POINT};

// Мост между SPI ESP32 и драйвером дисплея
use display_interface_spi::SPIInterface;
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::spi::Mode as SpiMode;
use esp_hal::time::Rate; // Для задания частоты SPI
use ili9341::{DisplaySize240x320, Ili9341, Orientation};

// Для управления состоянием GPIO
use esp_hal::gpio::{Level, Output, OutputConfig};
}

Настройка SPI

Инициализируем SPI-устройство для связи между ESP32 и дисплеем. Это стандартная настройка: сначала инициализируем шину SPI, а затем преобразуем её в SPI-устройство с помощью embedded-hal-bus.

#![allow(unused)]
fn main() {
// Инициализируем SPI
let spi = Spi::new(
    peripherals.SPI2,
    SpiConfig::default()
        .with_frequency(Rate::from_mhz(4))
        .with_mode(SpiMode::_0),
)
.unwrap()
//CLK
.with_sck(peripherals.GPIO18)
//DIN
.with_mosi(peripherals.GPIO23);
let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
let interface = SPIInterface::new(spi_dev, dc);
}

На этот раз мы добавили ещё один шаг: создаём SPIInterface с помощью крейта display-interface-spi. Этот интерфейс объединяет SPI-устройство и вывод данных/команд (DC) в одну абстракцию. Он упрощает обмен, сам управляя тем, как команды и данные отправляются по SPI. Этот интерфейс мы передадим драйверу TFT-дисплея.

Инициализация дисплея

Чтобы инициализировать дисплей, передаём драйверу Ili9341 SPI-интерфейс, вывод сброса, задержку, ориентацию и размер экрана. Так задаётся всё, что нужно драйверу для работы с дисплеем.

#![allow(unused)]
fn main() {
let mut display = Ili9341::new(
        interface,
        reset,
        &mut Delay::new(),
        Orientation::Portrait,
        DisplaySize240x320,
    )
    .unwrap();
}

Ориентацию задаём как портретную, то есть дисплей считается шириной 240 пикселей и высотой 320 пикселей. Размер экрана 240 на 320 пикселей соответствует разрешению дисплея. Вместе эти настройки помогают драйверу правильно выводить содержимое с учётом формы и размера дисплея.

Очистка дисплея

Очистим дисплей, залив фон белым цветом. Поскольку TFT — цветной дисплей, мы используем формат цвета Rgb565, который представляет 16-битные значения цвета (5 бит красного, 6 бит зелёного, 5 бит синего). Это первый раз, когда мы используем Rgb565: до сих пор мы работали только с монохромными дисплеями.

#![allow(unused)]
fn main() {
display.clear(Rgb565::WHITE).unwrap();
}

Вывод текста

Теперь наконец выведем текст «impl Rust для ESP32» на экран. Две части текста мы выведем отдельно, используя разные размеры шрифта и цвета.

#![allow(unused)]
fn main() {

let text_style = MonoTextStyle::new(&PROFONT_24_POINT, Rgb565::RED);
Text::with_baseline("impl Rust", Point::new(50, 150), text_style, Baseline::Top)
    .draw(&mut display)
    .unwrap();

let text_style = MonoTextStyle::new(&PROFONT_18_POINT, Rgb565::CSS_DIM_GRAY);

Text::with_baseline("for ESP32", Point::new(60, 180), text_style, Baseline::Top)
    .draw(&mut display)
    .unwrap();

}

Первую строку «impl Rust» мы рисуем красным шрифтом, на расстоянии 50 пикселей от левого края и 150 пикселей от верха экрана. Вторая строка «for ESP32» расположена чуть ниже: в 60 пикселях от левого края и в 180 пикселях от верха. Если выводите другой текст, смело меняйте координаты, чтобы добиться выравнивания и интервалов, которые выглядят лучше всего.

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку tft-display-hello.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/tft-display-hello/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_hal::time::{Duration, Instant};
use esp_println as _;

// Связанное с embedded-graphics
use embedded_graphics::mono_font::MonoTextStyle;
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};

// Крупный шрифт
use profont::{PROFONT_18_POINT, PROFONT_24_POINT};

// Мост между SPI ESP32 и драйвером дисплея
use display_interface_spi::SPIInterface;
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use esp_hal::spi::Mode as SpiMode;
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate; // Для задания частоты SPI
use ili9341::{DisplaySize240x320, Ili9341, Orientation};

// Для управления состоянием GPIO
use esp_hal::gpio::{Level, Output, OutputConfig};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    // Инициализируем SPI
    let spi = Spi::new(
        peripherals.SPI2,
        SpiConfig::default()
            .with_frequency(Rate::from_mhz(4))
            .with_mode(SpiMode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);
    let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
    let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

    let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
    let interface = SPIInterface::new(spi_dev, dc);

    let mut display = Ili9341::new(
        interface,
        reset,
        &mut Delay::new(),
        Orientation::Portrait,
        DisplaySize240x320,
    )
    .unwrap();

    display.clear(Rgb565::WHITE).unwrap();

    let text_style = MonoTextStyle::new(&PROFONT_24_POINT, Rgb565::RED);
    Text::with_baseline("impl Rust", Point::new(50, 150), text_style, Baseline::Top)
        .draw(&mut display)
        .unwrap();

    let text_style = MonoTextStyle::new(&PROFONT_18_POINT, Rgb565::CSS_DIM_GRAY);

    Text::with_baseline("for ESP32", Point::new(60, 180), text_style, Baseline::Top)
        .draw(&mut display)
        .unwrap();

    loop {
        info!("Hello world!");
        let delay_start = Instant::now();
        while delay_start.elapsed() < Duration::from_millis(500) {}
    }
}

Вывод изображения на TFT-дисплей с ESP32 и Rust

Выведем изображение на TFT-дисплей. Для этого можно использовать крейт embedded-graphics и, по желанию, крейт tinybmp.

Крейт tinybmp мы уже рассматривали в разделе про OLED. Он позволяет загрузить BMP-файл и вывести его на экран. Кроме того, тот же результат можно получить, используя сырой байтовый массив изображения.

Крейт требует, чтобы изображение было в формате BMP. Если ваше изображение в другом формате, его нужно конвертировать в BMP.

Я использую картинку, которую нашёл в интернете, но не уверен, кто её автор, чтобы правильно указать источник. Скачать изображение можно здесь. Можете взять другое изображение, но обязательно переведите его в формат BMP.

файл embedded-rust

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 tft-display-image

Откроется экран с вопросом о выборе опций.

  • Сначала выберите опцию «Enable unstable HAL features.»

Сохраните настройки, нажав на клавиатуре «s».

Обновляем cargo.toml

embedded-hal-bus = { version = "0.3" }
display-interface-spi = "0.5"
ili9341 = "0.6.0"
embedded-graphics = "0.8.1"

tinybmp = "0.6.0"

Подробности остальных крейтов и базовую настройку SPI и кода дисплея мы уже разобрали, поэтому повторяться не будем. Сразу перейдём к выводу изображения после очистки экрана.

Чёрный фон

Вместо белого фона зальём фон чёрным цветом.

#![allow(unused)]
fn main() {
display.clear(Rgb565::BLACK).unwrap();
}

Вывод изображения

Поместите файл embedded-rust.bmp в корневую папку проекта. Код довольно простой: загрузите изображение как байты и передайте их в функцию from_slice у Bmp. Затем используйте результат с Image.

#![allow(unused)]
fn main() {
let bmp_data = include_bytes!("../../embedded-rust.bmp");
let bmp = Bmp::from_slice(bmp_data).unwrap();

let image = Image::new(&bmp, Point::new(10, 0));
image.draw(&mut display).unwrap();

}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку tft-display-image.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/tft-display-image/

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use defmt::info;
use embedded_graphics::image::Image;
use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_hal::time::{Duration, Instant};
use esp_println as _;

// Связанное с embedded-graphics
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;

// Мост между SPI ESP32 и драйвером дисплея
use display_interface_spi::SPIInterface;
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use esp_hal::spi::Mode as SpiMode;
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate; // Для задания частоты SPI
use ili9341::{DisplaySize240x320, Ili9341, Orientation};

// Для управления состоянием GPIO
use esp_hal::gpio::{Level, Output, OutputConfig};
use tinybmp::Bmp;

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    // Инициализируем SPI
    let spi = Spi::new(
        peripherals.SPI2,
        SpiConfig::default()
            .with_frequency(Rate::from_mhz(4))
            .with_mode(SpiMode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);
    let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
    let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

    let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
    let interface = SPIInterface::new(spi_dev, dc);

    let mut display = Ili9341::new(
        interface,
        reset,
        &mut Delay::new(),
        Orientation::Portrait,
        DisplaySize240x320,
    )
    .unwrap();

    display.clear(Rgb565::BLACK).unwrap();

    let bmp_data = include_bytes!("../../embedded-rust.bmp");
    let bmp = Bmp::from_slice(bmp_data).unwrap();

    let image = Image::new(&bmp, Point::new(10, 0));
    image.draw(&mut display).unwrap();

    loop {
        info!("Hello world!");
        let delay_start = Instant::now();
        while delay_start.elapsed() < Duration::from_millis(500) {}
    }
}

Ratatui

Вероятно, вы знакомы с Ratatui — популярной библиотекой Rust для создания текстовых пользовательских интерфейсов (TUI). Многие разработчики создают с этим крейтом впечатляющие TUI-приложения. Но вы можете спросить, зачем мы обсуждаем библиотеку TUI в книге о встраиваемом Rust.

Ответ — Mousefood. Это бэкенд для embedded-graphics, который приносит Ratatui на микроконтроллеры. Это значит, что Ratatui можно запускать везде, где вы можете использовать embedded-graphics (например, на TFT-дисплеях, e-paper и других дисплеях).

Зачем использовать Ratatui во встраиваемых системах?

Ratatui значительно упрощает и ускоряет создание интерфейсов для встраиваемых устройств по сравнению с написанием всего с нуля. Вы получаете готовые виджеты, например диаграммы, таблицы и индикаторы прогресса, без сложного кода отрисовки.

Основы Ratatui

Если вы только начинаете работать с Ratatui, загляните на официальный сайт https://ratatui.rs/. Там есть понятная документация и руководства для новичков, которые помогут создать простые TUI-приложения, например JSON-редактор. Понимание базовых концепций Ratatui поможет адаптировать их для встраиваемых дисплеев.

Проекты с Ratatui во встраиваемых системах

Вот несколько интересных проектов, которые используют Ratatui во встраиваемых системах:

Tuitar

Tuitar — это инструмент для обучения игре на гитаре, созданный Орханом Пармаксызом. Он работает автономно на оборудовании ESP32 и показывает визуализацию в реальном времени по аудиовходу, помогая настраивать гитару и другие инструменты.

Tuitar может отслеживать ноты, которые вы играете на гитаре, в реальном времени и показывать их на виртуальном грифе.

Tuitar

Современная телефонная ОС для ESP32 CYD

Дисплей автомобиля: просмотрщик Suzuki Serial Data Line (SDL) на Rust

Примечание: этот проект не использует крейт mousefood и не является настоящим встраиваемым Ratatui. Код работает на Raspberry Pi в обычной среде Linux.

Ratatui на дисплее автомобиля

Привет, Rat

В этой главе мы напишем простую программу, чтобы начать работать с Ratatui. Здесь покажем только основы, чтобы понять, как это работает.

Предварительные требования

  • Для этой главы понадобится TFT-дисплей. Если вы ещё не прошли главу о TFT-дисплее, рекомендую сначала закончить её, а затем вернуться сюда. Поскольку схема подключения TFT-дисплея к ESP32 уже описана там, здесь мы не будем повторять эти инструкции.

  • У Ratatui есть хороший учебник для начала по созданию TUI-приложений. Если вы уже знаете Ratatui, это упражнение довольно простое: нужно просто соединить части вместе. Если вы новичок, позже изучите официальные руководства Ratatui, чтобы освоить основы и создавать более удобные интерфейсы.

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

Чтобы создать проект, используйте команду esp-generate. Выполните:

esp-generate --chip esp32 hello-rat

Откроется экран с вопросом о выборе опций. Ratatui требует выделения памяти в куче (heap), поэтому включим возможности «unstable» и «alloc».

  • Сначала выберите опцию «Enable unstable HAL features.»
  • Выберите опцию «Enable allocations via the esp-alloc crate.»

По желанию можно также включить логирование.

  • Прокрутите список до пункта «Flashing, logging and debugging (espflash)» и нажмите Enter.
  • Затем выберите «Use defmt to print messages».

Сохраните настройки, нажав на клавиатуре «s».

Зависимости

Сначала обновим файл Cargo.toml зависимостями, которые нужны для управления TFT-дисплеем. Мы уже рассматривали их в разделе о TFT-дисплее.

embedded-hal-bus = { version = "0.3" }
display-interface-spi = "0.5"
ili9341 = "0.6.0"
embedded-graphics = "0.8.1"

Tip

Хотя в этом примере для работы с TFT-дисплеем используется крейт ili9341, вы можете применять mousefood с любым крейтом драйвера дисплея, который реализует трейты embedded-graphics.

Теперь добавим крейты ratatui и mousefood, которые позволяют использовать ratatui во встраиваемой среде.

mousefood = { git = "https://github.com/j-g00da/mousefood", rev = "cc9f8fe372f09342537bc31a1355f77f2693d70b", default-features = false, features = [
  "fonts",
] }
ratatui = { version = "0.30.0-alpha.5", default-features = false }

Для поддержки no_std пока нужен самый свежий код mousefood с GitHub и совместимая альфа-версия ratatui.

Импорты

Подключим все необходимые крейты и модули для настройки и управления TFT-дисплеем. Мы импортируем всё для отрисовки графики через embedded-graphics, SPI-связь через esp-hal и драйвер дисплея ili9341. Наконец, добавим импорты, связанные с ratatui.

#![allow(unused)]
fn main() {
// Связанное с embedded-graphics
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;

// Мост между SPI ESP32 и драйвером дисплея
use esp_hal::delay::Delay;
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::spi::Mode as SpiMode;
use esp_hal::time::Rate; // Для задания частоты SPI
use display_interface_spi::SPIInterface;
use embedded_hal_bus::spi::ExclusiveDevice;
use ili9341::{DisplaySize240x320, Ili9341, Orientation};

// Для управления состоянием GPIO
use esp_hal::gpio::{Level, Output, OutputConfig};

// Для ratatui
use mousefood::{EmbeddedBackend, EmbeddedBackendConfig};
use ratatui::layout::{Constraint, Flex, Layout};
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::{style::*, Frame, Terminal};
}

Инициализация драйвера TFT-дисплея

Инициализируем TFT-дисплей: сначала настроим интерфейс SPI, затем создадим и сконфигурируем экземпляр дисплея.

#![allow(unused)]
fn main() {
// Инициализируем SPI
  let spi = Spi::new(
      peripherals.SPI2,
      SpiConfig::default()
          .with_frequency(Rate::from_mhz(60))
          .with_mode(SpiMode::_0),
  )
  .unwrap()
  //CLK
  .with_sck(peripherals.GPIO18)
  //DIN
  .with_mosi(peripherals.GPIO23);
  let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
  let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
  let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

  let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
  let interface = SPIInterface::new(spi_dev, dc);

  let mut display = Ili9341::new(
      interface,
      reset,
      &mut Delay::new(),
      Orientation::Landscape,
      DisplaySize240x320,
  )
  .unwrap();
  display.clear(Rgb565::BLACK).unwrap();
}

Бэкенды в Ratatui

Ratatui не общается с экраном напрямую. Вместо этого он использует посредника, который называется «бэкенд» и занимается всеми низкоуровневыми операциями терминала. Представьте бэкенд как переводчика между высокоуровневыми командами отрисовки Ratatui и реальным терминалом или аппаратным обеспечением дисплея.

Ratatui поддерживает разные бэкенды, благодаря чему его можно использовать в разных окружениях. По умолчанию он использует Crossterm как бэкенд для обычных эмуляторов терминала. Однако для встраиваемых систем это не подходит. Нам нужен бэкенд, который работает во встраиваемых окружениях. Здесь на помощь приходит крейт mousefood, который предоставляет встраиваемый бэкенд (Embedded Backend).

Этот бэкенд позволяет Ratatui выводить изображение на ЖК-экраны микроконтроллеров, e-ink дисплеи, небольшие OLED-экраны и любое другое аппаратное обеспечение дисплеев, которое поддерживает библиотеку embedded-graphics.

Встраиваемый бэкенд Ratatui

Подробнее о бэкендах Ratatui см. здесь.

Чтобы Ratatui использовал встраиваемый бэкенд mousefood, сначала инициализируем EmbeddedBackend, передав ему изменяемую ссылку на дисплей и конфигурацию по умолчанию:

#![allow(unused)]
fn main() {
let backend = EmbeddedBackend::new(&mut display, EmbeddedBackendConfig::default());
}

Затем создадим терминал Ratatui с этим бэкендом:

#![allow(unused)]
fn main() {
let mut terminal = Terminal::new(backend).unwrap();
}

Функция отрисовки

Определим функцию draw, которая задаёт макет и визуальные элементы нашего интерфейса. Сначала создадим внешний блок с зелёной рамкой и заголовком «ESP32 Dashboard», который окружает весь интерфейс. Внутри этого блока организуем макет сначала по вертикали, затем по горизонтали, чтобы задать области для содержимого.

Затем создадим виджет-параграф с текстом «Rat(a tui) inside ESP32». Заключим его в блок с жёлтой рамкой и заголовком « impl Rust » и выведем в центре, который задали с помощью макета.

#![allow(unused)]
fn main() {
fn draw(frame: &mut Frame) {

    let outer_block = Block::bordered()
        .border_style(Style::new().green())
        .title(" ESP32 Dashboard ");
    frame.render_widget(outer_block, frame.area());

    let vertical_layout = Layout::vertical([Constraint::Length(3)])
        .flex(Flex::Center)
        .split(frame.area());

    let horizontal_layout = Layout::horizontal([Constraint::Length(25)])
        .flex(Flex::Center)
        .split(vertical_layout[0]);

    let text = "Rat(a tui) inside ESP32";
    let paragraph = Paragraph::new(text.dark_gray())
        .wrap(Wrap { trim: true })
        .centered();

    let bordered_block = Block::bordered()
        .border_style(Style::new().yellow())
        .title(" impl Rust ");

    frame.render_widget(paragraph.block(bordered_block), horizontal_layout[0]);
}
}

Отрисовка

Наконец, вызываем метод отрисовки Ratatui, передавая нашу функцию draw, чтобы вывести интерфейс на дисплей.

#![allow(unused)]
fn main() {
loop {
  terminal.draw(draw).unwrap();
}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_println as _;

// Связанное с embedded-graphics
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;

// Мост между SPI ESP32 и драйвером дисплея
use display_interface_spi::SPIInterface;
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use esp_hal::spi::Mode as SpiMode;
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate; // Для задания частоты SPI
use ili9341::{DisplaySize240x320, Ili9341, Orientation};

// Для управления состоянием GPIO
use esp_hal::gpio::{Level, Output, OutputConfig};

// Для ratatui
use mousefood::{EmbeddedBackend, EmbeddedBackendConfig};
use ratatui::layout::{Constraint, Flex, Layout};
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::{Frame, Terminal, style::*};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern crate alloc;

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    // Инициализируем SPI
    let spi = Spi::new(
        peripherals.SPI2,
        SpiConfig::default()
            .with_frequency(Rate::from_mhz(60))
            .with_mode(SpiMode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);
    let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
    let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

    let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
    let interface = SPIInterface::new(spi_dev, dc);

    let mut display = Ili9341::new(
        interface,
        reset,
        &mut Delay::new(),
        Orientation::Landscape,
        DisplaySize240x320,
    )
    .unwrap();
    display.clear(Rgb565::BLACK).unwrap();

    let backend = EmbeddedBackend::new(&mut display, EmbeddedBackendConfig::default());

    let mut terminal = Terminal::new(backend).unwrap();
    loop {
        terminal.draw(draw).unwrap();
    }
}

fn draw(frame: &mut Frame) {
    let outer_block = Block::bordered()
        .border_style(Style::new().green())
        .title(" ESP32 Dashboard ");
    frame.render_widget(outer_block, frame.area());

    let vertical_layout = Layout::vertical([Constraint::Length(3)])
        .flex(Flex::Center)
        .split(frame.area());

    let horizontal_layout = Layout::horizontal([Constraint::Length(25)])
        .flex(Flex::Center)
        .split(vertical_layout[0]);

    let text = "Rat(a tui) inside ESP32";
    let paragraph = Paragraph::new(text.dark_gray())
        .wrap(Wrap { trim: true })
        .centered();

    let bordered_block = Block::bordered()
        .border_style(Style::new().yellow())
        .title(" impl Rust ");

    frame.render_widget(paragraph.block(bordered_block), horizontal_layout[0]);
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку hello-rat.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/hello-rat/

Прошивка программы

Выполните следующую команду из папки проекта, чтобы собрать программу и прошить её в ESP32:

#![allow(unused)]
fn main() {
cargo run --release
}

Теперь на дисплее должен появиться интерфейс Ratatui.

Ratatui на ESP32

Если хотите использовать крейт mipidsi вместо ili9341, посмотрите следующее руководство.

Использование крейта mipidsi

Это руководство показывает, как использовать более новый крейт mipidsi с TFT-дисплеем и ESP32.

Крейт mipidsi — универсальный крейт для нескольких дисплеев; он также поддерживает ili9341.

Настройка

Настройка такая же, как в предыдущей программе, за исключением нескольких изменений в Cargo.toml.

Зависимости в Cargo.toml

embedded-hal-bus = "0.3.0"
embedded-graphics = "0.8.1"
mipidsi = "0.9.0"
static_cell = "2.1.1"

Крейт static_cell предоставляет безопасный способ выделить память или управлять динамической памятью.

Это важно, потому что буфер SPI должен существовать всё время работы программы, но его нельзя инициализировать раньше времени выполнения, когда настраивается периферия.

mousefood и ratatui

Добавьте в Cargo.toml следующие версии mousefood и ratatui:

mousefood = { git = "https://github.com/j-g00da/mousefood", rev = "cc9f8fe372f09342537bc31a1355f77f2693d70b", default-features = false, features = [
  "fonts",
] }
ratatui = { version = "0.30.0-alpha.5", default-features = false }

Импорты

#![allow(unused)]
fn main() {
use static_cell::StaticCell;
use embedded_hal_bus::spi::ExclusiveDevice;

// Всё для ESP
use esp_hal::{
  delay::Delay,
  spi::{
    master::{
      Config as SpiConfig,
      Spi
    },
    Mode as SpiMode,
  },
  time::Rate,
  gpio::{
    Level,
    Output,
    OutputConfig
  },
  clock::CpuClock,
  main
};

// Всё для embedded-graphics
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;

// Всё для TFT-экрана
use mipidsi::{Builder, models::ILI9342CRgb565, interface::SpiInterface, options::{Orientation, Rotation}};

// Всё для mousefood
use mousefood::{EmbeddedBackend, EmbeddedBackendConfig};
use ratatui::{layout::{Constraint, Flex, Layout}, widgets::{Block, Paragraph, Wrap}};
use ratatui::{style::*, Frame, Terminal};
}

Здесь используется несколько переменных конфигурации. Этот код идёт после импортов:

#![allow(unused)]
fn main() {
static SPI_BUFFER: StaticCell<[u8; 512]> = StaticCell::new();
}

Инициализация драйвера TFT-дисплея

#![allow(unused)]
fn main() {
let spi = Spi::new(
  peripherals.SPI2,
  SpiConfig::default()
    .with_frequency(Rate::from_mhz(60))
    .with_mode(SpiMode::_0)
)
  .unwrap()
  .with_sck(peripherals.GPIO18)
  .with_mosi(peripherals.GPIO23);

let cs = Output::new(peripherals.GPIO5, Level::Low, OutputConfig::default());
let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

let buffer = SPI_BUFFER.init([0; 512]);

let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
let interface = SpiInterface::new(spi_dev, dc, buffer);

let mut display = Builder::new(
  ILI9342CRgb565,
  interface
)
  .reset_pin(reset)
  .init(&mut Delay::new())
  .unwrap();

// ВАЖНО: задаём ориентацию ДО очистки и создания бэкенда
display.set_orientation(
  Orientation::default().rotate(Rotation::Deg270)
).unwrap();

// Очищаем с новой ориентацией
display.clear(Rgb565::BLACK).unwrap();
}

Здесь вместо драйвера ili9341 используется ILI9342CRgb565 из-за проблем совместимости. Если возникнут проблемы с дисплеем, можно заменить ILI9342CRgb565 на ILI9341Rgb565.

Создание бэкенда

Чтобы создать бэкенд, используем встраиваемый бэкенд mousefood:

#![allow(unused)]
fn main() {
let backend = EmbeddedBackend::new(&mut display, EmbeddedBackendConfig::default());
}

Затем создаём терминал ratatui, который использует этот бэкенд:

#![allow(unused)]
fn main() {
let mut terminal = Terminal::new(backend).unwrap();
}

Функция отрисовки

Эта функция рисует визуальные элементы TUI.

#![allow(unused)]
fn main() {
fn draw(frame: &mut Frame) {
    let outer_block = Block::bordered()
        .title_style(Style::new().green())
        .title("ESP32 Dashboard");

    frame.render_widget(outer_block, frame.area());

    let vertical_layout = Layout::vertical([Constraint::Length(3)])
        .flex(Flex::Center)
        .split(frame.area());

    let horizontal_layout = Layout::horizontal([Constraint::Length(25)])
        .flex(Flex::Center)
        .split(vertical_layout[0]);

    let text = "Rat(a tui) inside ESP32";
    let paragraph = Paragraph::new(text.dark_gray())
        .wrap(Wrap { trim: true })
        .centered();

    let bordered_block = Block::bordered()
        .border_style(Style::new().yellow())
        .title("impl rust");

    frame.render_widget(paragraph.block(bordered_block), horizontal_layout[0]);

}
}

Отрисовка

В функции main, после создания terminal:

#![allow(unused)]
fn main() {
loop {
  terminal.draw(draw).unwrap();
}
}

Полный код

#![no_std]
#![no_main]
#![deny(
    clippy::mem_forget,
    reason = "mem::forget is generally not safe to do with esp_hal types, especially those \
    holding buffers for the duration of a data transfer."
)]

use esp_hal::clock::CpuClock;
use esp_hal::main;
use esp_println as _;

// Связанное с embedded-graphics
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;

// Мост между SPI ESP32 и драйвером дисплея
use embedded_hal_bus::spi::ExclusiveDevice;
use esp_hal::delay::Delay;
use esp_hal::spi::Mode as SpiMode;
use esp_hal::spi::master::Config as SpiConfig;
use esp_hal::spi::master::Spi;
use esp_hal::time::Rate; // Для задания частоты SPI

use static_cell::StaticCell;

// Для TFT-экрана
use mipidsi::{
    Builder,
    interface::SpiInterface,
    models::ILI9342CRgb565,
    options::{Orientation, Rotation},
};

// Для управления состоянием GPIO
use esp_hal::gpio::{Level, Output, OutputConfig};

// Для ratatui
use mousefood::{EmbeddedBackend, EmbeddedBackendConfig};
use ratatui::layout::{Constraint, Flex, Layout};
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::{Frame, Terminal, style::*};

#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

extern crate alloc;

// Создаёт дескриптор приложения по умолчанию, который требуется загрузчику esp-idf.
// Подробнее см.: <https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/app_image_format.html#application-description>
esp_bootloader_esp_idf::esp_app_desc!();

static SPI_BUFFER: StaticCell<[u8; 512]> = StaticCell::new();

#[main]
fn main() -> ! {
    // generator version: 1.0.0

    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);

    esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 98767);

    // Инициализируем SPI
    let spi = Spi::new(
        peripherals.SPI2,
        SpiConfig::default()
            .with_frequency(Rate::from_mhz(60))
            .with_mode(SpiMode::_0),
    )
    .unwrap()
    //CLK
    .with_sck(peripherals.GPIO18)
    //DIN
    .with_mosi(peripherals.GPIO23);
    let cs = Output::new(peripherals.GPIO15, Level::Low, OutputConfig::default());
    let dc = Output::new(peripherals.GPIO2, Level::Low, OutputConfig::default());
    let reset = Output::new(peripherals.GPIO4, Level::Low, OutputConfig::default());

    let buffer = SPI_BUFFER.init([0; 512]);

    let spi_dev = ExclusiveDevice::new_no_delay(spi, cs).unwrap();
    let interface = SpiInterface::new(spi_dev, dc, buffer);

    let mut display = Builder::new(ILI9342CRgb565, interface)
        .reset_pin(reset)
        .init(&mut Delay::new())
        .unwrap();

    // ВАЖНО: задаём ориентацию ДО очистки и создания бэкенда
    display
        .set_orientation(Orientation::default().rotate(Rotation::Deg270))
        .unwrap();

    // Очищаем с новой ориентацией
    display.clear(Rgb565::BLACK).unwrap();

    let backend = EmbeddedBackend::new(&mut display, EmbeddedBackendConfig::default());

    let mut terminal = Terminal::new(backend).unwrap();
    loop {
        terminal.draw(draw).unwrap();
    }
}

fn draw(frame: &mut Frame) {
    let outer_block = Block::bordered()
        .border_style(Style::new().green())
        .title(" ESP32 Dashboard ");
    frame.render_widget(outer_block, frame.area());

    let vertical_layout = Layout::vertical([Constraint::Length(3)])
        .flex(Flex::Center)
        .split(frame.area());

    let horizontal_layout = Layout::horizontal([Constraint::Length(25)])
        .flex(Flex::Center)
        .split(vertical_layout[0]);

    let text = "Rat(a tui) inside ESP32";
    let paragraph = Paragraph::new(text.dark_gray())
        .wrap(Wrap { trim: true })
        .centered();

    let bordered_block = Block::bordered()
        .border_style(Style::new().yellow())
        .title(" impl Rust ");

    frame.render_widget(paragraph.block(bordered_block), horizontal_layout[0]);
}

Клонирование существующего проекта

Вы также можете клонировать (или посмотреть) созданный мной проект и перейти в папку hello-rat-2.

git clone https://github.com/ImplFerris/esp32-projects
cd esp32-projects/hello-rat-2/

Прошивка программы

Выполните следующую команду из папки проекта (с Cargo.toml), чтобы прошить программу в ESP32:

cargo run --release
Ratatui на ESP32

Подборка проектов на Rust для ESP32

Здесь собраны интересные проекты, которые я нашёл в интернете и которые связаны с ESP32 и Rust. Если у вас есть интересный проект, которым хотите поделиться, присылайте PR :)

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

ESP32

  • ESP32 Rex: игра про динозаврика для ESP32 с OLED-дисплеем на фреймворке Embassy.
  • ESP32 RFID Access Control: имитация умного дверного замка на Rust и ESP32: RFID, необязательный сервопривод и OLED-дисплей для имитации и управления доступом через дверь.
  • ESP32 Wi-Fi Tank: танк или ровер с управлением по Wi-Fi, построенный на плате управления ESP32 и Rust.
  • Solar Inverter: солнечный инвертор, подключаемый к сети, с MPPT.
  • Paper train: выводит задержки поездов NMBS на e-ink-дисплей, управляемый ESP32.
  • Snake Game: игра «Змейка» для ESP32 с подключённым OLED-дисплеем, управляемая джойстиком.

ESP32-C3

  • ha-vfd-dashboard: панель Home Assistant на вакуумном люминесцентном дисплее.
  • ESP32-C3 Embassy: собирает данные об окружающей среде (температура, влажность, давление) с датчика BME280 по I²C и выводит последнее значение на E-INK-дисплей WaveShare 1.54 дюйма (модель B, версия 2) по SPI.

ESP32-S3

  • anemometer: анемометр на базе ESP32-S3 от Espressif, напечатанный на 3D-принтере.

Общее

  • esp32-spooky-maze-game: реализация игры-лабиринта для ESP32 на Rust в режиме bare metal.