Перейти до змісту

Async без зайвого болю

Тут вже буде не просто .await посеред чужого коду. Зробимо окрему прошивку: кнопка A збільшує число на дисплеї, а друга задача щосекунди пише повідомлення в лог. Радіо для цього не треба.

Що підключаємо

Повний проєкт лежить у examples/async-counter/. Конфігурація тулчейна, лінкер і build.rs вже є в цій теці.

[package]
name = "async-counter"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false

[dependencies]
lilkaoxide = { path = "../../sdk-hal", default-features = false, features = ["async", "AsyncBus", "alloc", "display_buffer_1024"] }
embedded-graphics = "0.8.2"
heapless = "0.9.1"
esp-alloc = { version = "0.10.0", features = ["defmt"] }
esp-bootloader-esp-idf = { version = "0.5.0", features = ["esp32s3", "defmt"] }
esp-println = { version = "0.16.1", features = ["esp32s3", "defmt-espflash"] }

esp-rtos = { version = "0.3.0", features = ["esp32s3", "embassy", "esp-alloc"] }
static_cell = "2.1.1"
lcd-async = "0.1.3"
embassy-executor = { version = "0.10.0", features = ["defmt"] }

blocking замінюємо на async, а RefCellBus — на AsyncBus. default-features = false тут обов'язковий, інакше SDK додасть свої blocking defaults. Шлях до SDK в маніфесті розрахований на цей репозиторій.

embassy-executor є прямою залежністю: макрос #[embassy_executor::task] генерує звернення до цього крейта. Одного реекспорту через SDK для нього недостатньо. esp-rtos дає точку входу, а не змушує нас самостійно ініціалізувати дисплей чи GPIO.

Повний код

#![no_std]
#![no_main]

use core::fmt::Write;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use embedded_graphics::{
    mono_font::{MonoTextStyle, ascii::FONT_6X10},
    pixelcolor::Rgb565,
    prelude::*,
    text::{Baseline, Text},
};
use esp_println::{self as _, println};
use lcd_async::raw_framebuf::RawFrameBuf;
use lilkaoxide::prelude::*;
use static_cell::StaticCell;

esp_bootloader_esp_idf::esp_app_desc!();
const WIDTH: usize = 280;
const STRIP_HEIGHT: usize = 24;
// 13 440 bytes instead of a full 134 400-byte frame.
static PIXELS: StaticCell<[u8; WIDTH * STRIP_HEIGHT * 2]> = StaticCell::new();

#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
    println!("PANIC: {}", info);
    loop {
        core::hint::spin_loop();
    }
}

#[embassy_executor::task]
async fn heartbeat() {
    loop {
        Timer::after(Duration::from_secs(1)).await;
        println!("Background task is alive");
    }
}

#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
    esp_alloc::heap_allocator!(size: 16 * 1024);
    let mut config = Configuration::default();
    config.buzzer_config.duty_pct = 0;
    let lilka = Lilka::init(config).await.expect("Lilka init failed");
    spawner.spawn(heartbeat().expect("Heartbeat task pool exhausted"));
    let controller = lilka.controller;
    let mut display = lilka.display;
    let bytes = PIXELS.init_with(|| [0; WIDTH * STRIP_HEIGHT * 2]);
    let mut strip = RawFrameBuf::<Rgb565, _>::new(bytes.as_mut_slice(), WIDTH, STRIP_HEIGHT);
    for y in (0..240).step_by(STRIP_HEIGHT) {
        display
            .show_raw_data(
                0,
                y as u16,
                WIDTH as u16,
                STRIP_HEIGHT as u16,
                strip.as_bytes(),
            )
            .await
            .unwrap();
    }
    let mut count = 0u32;
    let mut was_pressed = false;
    let mut redraw = true;
    loop {
        let pressed = controller.a.is_low();
        if pressed && !was_pressed {
            count = count.wrapping_add(1);
            redraw = true;
        }
        was_pressed = pressed;
        if redraw {
            strip.clear(Rgb565::BLACK).unwrap();
            let mut text: heapless::String<48> = heapless::String::new();
            let _ = write!(text, "Async: A pressed {} times", count);
            Text::with_baseline(
                &text,
                Point::new(8, 6),
                MonoTextStyle::new(&FONT_6X10, Rgb565::CYAN),
                Baseline::Top,
            )
            .draw(&mut strip)
            .unwrap();
            display
                .show_raw_data(0, 24, WIDTH as u16, STRIP_HEIGHT as u16, strip.as_bytes())
                .await
                .unwrap();
            redraw = false;
        }
        Timer::after(Duration::from_millis(30)).await;
    }
}

Навіщо тут смуга замість повного кадру

embedded-graphics малює синхронно, а lcd-async передає пікселі асинхронно. З'єднуємо їх через RawFrameBuf: спочатку текст потрапляє у RAM, потім show_raw_data(...).await передає готові байти на дисплей.

Для одного рядка немає сенсу тримати всі 134400 байтів повного кадру. Тут смуга 280×24 займає 13440 байтів. Під час старту нею чиститься весь екран, а далі оновлюється тільки ділянка на y = 24. Координати тексту всередині буфера локальні: його (0, 0) — початок смуги, а не дисплея.

StaticCell::init_with створює цей буфер у статичній пам'яті. Великий масив не потрібно спочатку складати на стек, а потім переносити.

Де тут паралельність

heartbeat() — окрема задача. spawner.spawn(...) запускає її один раз; це не треба робити на кожній ітерації. В Embassy 0.10 помилку вичерпання пулу перевіряємо у heartbeat().expect(...), а сам spawn вже повертає ().

Коли головна задача доходить до Timer::after(...).await, вона віддає виконання executor. Фоновий таймер при цьому може продовжувати роботу. Довгий синхронний цикл без .await такого не робить: async сам по собі не перетворює його на окремий потік.

У прикладі дисплеєм володіє тільки головна задача. Кнопка опитується раз на 30 мс і рахується за переходом у натиснутий стан; це спрощене опитування без повного debounce. Якщо треба передавати дані між задачами, використовуйте Signal або Channel, а не спільний static mut.

Як перевірити

cd examples/async-counter
cargo +esp build --release --locked
cargo +esp run --release --locked

На екрані має з'явитися Async: A pressed 0 times. Після натискання A число змінюється, а в лог щосекунди приходить Background task is alive. Для запуску з Keira створіть application .bin звичайною командою, замінивши назву вихідного файла.

Одна конфігурація SDK за раз

Збирайте цей приклад зі своєї теки. Збірка всього workspace об'єднає features з blocking-пакетів і дасть несумісну конфігурацію. AsyncBus не можна замінити AtomicBus або додати поряд із RefCellBus.