Lập trình ESP32 với Rust: cập nhật firmware OTA

AgriConnect, anh em đã sử dụng Rust để lập trình firmware cho ESP32 (và cả một phần backend của nền tảng IoT). Từ hôm nay tôi sẽ bắt đầu một loạt bài nhằm giúp những đồng đội trên hành trình này. "Cập nhật firmware qua mạng (OTA / over-the-air)" là bài chia sẻ đầu tiên vì đây là chủ đề mà một trong những tác giả của esp-idf-hal đã đề nghị tôi viết.

Để có tính năng cập nhật firmware OTA, esp-if-svc đã cung cấp API rồi, nhưng còn thiếu tài liệu hướng dẫn sử dụng API, cách chuẩn bị phân vùng để sử dụng API. Bài viết này là để bổ sung chỗ thiếu đó.

(English version is here.)

Chuẩn bị phân vùng

Để tính năng này hoạt động được, bộ nhớ flash trong ESP32 của bạn phải có ít nhất các phân vùng sau: otadata (thuộc kiểu con ota), app0 (thuộc kiểu con ota_0), app1 (thuộc kiểu con ota_1).

Name Type SubType
otadata data ota
app0 app ota_0
app1 app ota_1

Bạn có thể sao chép một trong các tệp mô tả bảng phân vùng trong kho Arduino-ESP32. Nhớ chọn cái phù hợp với kích thước flash trong thiết bị của bạn.

Sau khi tải xuống, đổi tên tệp thành partitions.csv và lưu vào thư mục trên cùng của bộ source code. Sau này, khi flash firmware, hãy thêm tùy chọn --partition-table vào lệnh espflash. Ví dụ, trong trường hợp của tôi, nó sẽ là:

$ espflash flash -B 921600 -s 8mb --partition-table=partitions.csv target/xtensa-esp32s3-espidf/release/ebee

Cơ bản

Cách sử dụng cơ bản của API Esp OTA như sau:

  • Tạo một đối tượng EspOta.
  • Tạo một đối tượng EspOtaUpdate từ đối tượng EspOta trước đó.
  • Gọi EspOtaUpdate.write() với buffer chứa firmware mới.
  • Gọi EspOtaUpdate.complete() sau khi ghi xong. Thao tác này sẽ đánh dấu phân vùng để khởi động lần sau.

Code đại khái sẽ như thế này:

use esp_idf_svc::ota::EspOta;
use esp_idf_svc::sys::EspError;

fn oversimplified_update_firmware() -> Result<(), EspError> {
    let mut ota = EspOta::new()?;
    let mut work = ota.initiate_update()?;
    let buff: Vec<u8> = get_firmware_buffer_from_somewhere();
    work.write(&buff)?;
    work.complete()
}

Nhưng trên thực tế, bạn cần phải làm nhiều việc hơn. Cái vướng mắc là, RAM của ESP32 không đủ lớn để tải xuống toàn bộ firmware: bạn sẽ không có buffer chứa toàn bộ file nhị phân của firmware như dòng 7.

Lưu ý rằng, trong chương trình, chúng ta thiết kế các hàm để trả về Result và bên trong hàm, chúng ta sử dụng toán tử ? để tận dụng phong cách xử trí lỗi tuyệt vời của Rust. Tôi có một bài viết khác về xử trí lỗi kiểu Rust tại đây.

Trường hợp đơn giản

Bây giờ chúng ta làm cho hàm này trở nên thực tế hơn, tức là nó nhận được một URL, tải xuống từng đoạn firmware từ URL đó và ghi ngay vào Flash trước khi tải xuống đoạn tiếp theo. Bằng cách đó, chúng ta có thể giải quyết vấn đề RAM. Nhưng chúng ta phải chấp nhận nhược điểm: chúng ta không có cơ hội kiểm tra tệp lành mạnh trước khi ghi vào Flash.

Hàm sẽ trông như thế này:

use core::mem::size_of;

use http::header::ACCEPT;
use http::Uri;
use embedded_svc::ota::FirmwareInfo;
use embedded_svc::http::{client::Client, Method};
use esp_idf_svc::http::client::{Configuration, EspHttpConnection};
use esp_idf_svc::ota::EspOta;
use esp_idf_svc::sys::{EspError, ESP_ERR_IMAGE_INVALID, ESP_ERR_INVALID_RESPONSE};

const FIRMWARE_DOWNLOAD_CHUNK_SIZE: usize = 1024 * 20;
// Not expect firmware bigger than 2MB
const FIRMWARE_MAX_SIZE: usize = 1024 * 1024 * 2;
const FIRMWARE_MIN_SIZE: usize = size_of::<FirmwareInfo>() + 1024;

pub fn simple_download_and_update_firmware(url: Uri) -> Result<(), EspError> {
    let mut client = Client::wrap(EspHttpConnection::new(&Configuration {
        buffer_size: Some(1024 * 4),
        ..Default::default()
    })?);
    let headers = [(ACCEPT.as_str(), mime::APPLICATION_OCTET_STREAM.as_ref())];
    let surl = url.to_string();
    let request = client
        .request(Method::Get, &surl, &headers)
        .map_err(|e| e.0)?;
    let mut response = request.submit().map_err(|e| e.0)?;
    if response.status() != 200 {
        log::info!("Bad HTTP response: {}", response.status());
        return Err(esp_err!(ESP_ERR_INVALID_RESPONSE));
    }
    let file_size = response.content_len().unwrap_or(0) as usize;
    if file_size <= FIRMWARE_MIN_SIZE {
        log::info!(
            "File size is {file_size}, too small to be a firmware! No need to proceed further."
        );
        return Err(esp_err!(ESP_ERR_IMAGE_INVALID));
    }
    if file_size > FIRMWARE_MAX_SIZE {
        log::info!("File is too big ({file_size} bytes).");
        return Err(esp_err!(ESP_ERR_IMAGE_INVALID));
    }
    let mut ota = EspOta::new()?;
    let mut work = ota.initiate_update()?;
    let mut buff = vec![0; FIRMWARE_DOWNLOAD_CHUNK_SIZE];
    let mut total_read_len: usize = 0;
    let mut got_info = false;
    let dl_result = loop {
        let n = response.read(&mut buff).unwrap_or_default();
        total_read_len += n;
        if !got_info {
            match get_firmware_info(&buff[..n]) {
                Ok(info) => log::info!("Firmware to be downloaded: {info:?}"),
                Err(e) => {
                    log::error!("Failed to get firmware info from downloaded bytes!");
                    break Err(e);
                }
            };
            got_info = true;
        }
        if n > 0 {
            if let Err(e) = work.write(&buff[..n]) {
                log::error!("Failed to write to OTA. {e}");
                break Err(e);
            }
        }
        if total_read_len >= file_size {
            break Ok(());
        }
    };
    if dl_result.is_err() {
        return work.abort();
    }
    if total_read_len < file_size {
        log::error!("Supposed to download {file_size} bytes, but we could only get {total_read_len}. May be network error?");
        return work.abort();
    }
    work.complete()
}

Tại sao đầu vào lại là một URL? Bởi vì HTTP là phương tiện thuận tiện nhất để tải firmware qua mạng. Ngoài ra, chúng ta nên chia chương trình của mình thành các đơn vị. Đơn vị này dùng để tải xuống và ghi firmware. Đơn vị khác thì lấy URL. Bước lấy URL là không giống nhau giữa các dự án/công ty. Trong một số dự án, ứng dụng ESP32 tìm URL bằng cách nhìn vào tệp JSON nào đó được lưu trữ trên S3. Trong trường hợp khác, ứng dụng ESP32 nhận được URL nhờ máy chủ đẩy xuống thông qua MQTT, WebSocket. Trong một trong những dự án cũ của mình, tôi đã tạo một server API bằng Django và ESP32 gọi API đó để kiểm tra có firmware mới không rồi tải xuống từ đó. Trong bài đăng này, tôi sẽ không nói về phần "làm thế nào để có URL của firmware".

Bây giờ tôi giải thích hàm simple_download_and_update_firmware.

Hàm này lấy tham số đầu vào thuộc kiểu Uri (từ crate http).

Tại sao Uri thay vì &str? Đó là vì chúng ta muốn đảm bảo URL nhận được là URL hợp pháp chứ không phải chuỗi tùy ý. Chúng ta không cần gọi hàm này nếu chuỗi chúng ta thu được ở bước trước không phải là URL. Bên ngoài, hàm này chỉ được gọi sau khi phân tích chuỗi thành URL, như sau:

fn outer_function() -> Result<(), AppError> {
    let url = get_url_from_somewhere(); // "https://agriconnect.vn/firmware.bin";
    if let Ok(u: Uri) = Uri::try_from(url) {
        simple_download_and_update_firmware(url: u)?
    } else {
        log::warn!("Invalid URL to download firmware");
    }
    Ok(())
}

Để tải firmware qua HTTP, tôi sử dụng thư viện HTTP client có sẵn trong esp-idf-svc. Bạn có thể sử dụng một số thư viện HTTP client từ https://crates.io, nhưng tôi chưa thử.

Trong đoạn mã trên, các giá trị buffer_size, FIRMWARE_DOWNLOAD_CHUNK_SIZE, FIRMWARE_MAX_SIZE, FIRMWARE_MIN_SIZE chỉ là từ thực nghiệm. Tôi chưa có quy tắc để suy ra chúng. Bạn có thể thử với các giá trị lớn hơn, chỉ cần cẩn thận với sự cố stack overflow (tràn bộ nhớ ngăn xếp)!

Macro esp_err! chỉ là macro của riêng tôi. Nó được định nghĩa như sau:

/// Macro to quickly create EspError from an ESP_ERR_ constant.
#[macro_export]
macro_rules! esp_err {
    ($x:ident) => {
        EspError::from_infallible::<$x>()
    };
}

Nếu không có nó, một số dòng trong ví dụ trên sẽ trông như thế này:

return Err(EspError::from_infallible::<ESP_ERR_INVALID_RESPONSE>());

Khá dài dòng, nhỉ?

Ở đây, ta giả định rằng URL tải xuống là HTTP thuần túy, không phải HTTPS, vì vậy HTTP client được cấu hình đơn giản như sau:

let mut client = Client::wrap(EspHttpConnection::new(&Configuration {
	buffer_size: Some(1024 * 4),
	..Default::default()
})?);

Nếu bạn tải qua HTTPS thì phần config sẽ là:

use esp_idf_svc::sys::esp_crt_bundle_attach;

...
Configuration {
	buffer_size: Some(1024 * 4),
	use_global_ca_store: true,
	crt_bundle_attach: Some(esp_crt_bundle_attach),
	..Default::default()
}

Trong các dòng liên quan đến việc gọi các phương thức của HTTP client, bạn sẽ thấy:

.map_err(|e| e.0)

Đó là bởi vì các phương thức này trả về Result<T, EspIOError> nhưng hàm của chúng ta mong đợi lỗi là EspError. Chúng ta có map_err(|e| e.0) để trích xuất EspError từ EspIOError.

Bạn có thể xem tài liệu về EspIoError tại đây:

pub struct EspIOError(pub EspError);

Điều này có nghĩa là EspIOError là một tuple struct (struct mà các không có tên và chúng ta tham chiếu đến một field bằng chỉ số của nó, như 0, 1, 2). Field đầu tiên và duy nhất của nó là kiểu EspError và may mắn thay nó là công khai (pub), nên có thể lấy nó qua .0.

Ở dòng 21, chúng ta sử dụng các hằng số từ các thư viện httpmime, để tránh lỗi đánh máy khi tạo các header cho HTTP.

Tại các dòng 27 - 41, chúng ta thực hiện một số kiểm tra đơn giản, để loại bỏ những phản hồi HTTP không hợp lệ.

Để nhận phân đoạn sắp tải xuống, chúng ta định nghĩa một buffer ở dòng 44, sau đó chúng ta đọc phân đoạn của firmware từ phản hồi HTTP ở dòng 49, lưu vào buffer, và ghi phân đoạn đã tải xuống vào bộ nhớ flash ở dòng 52. Các hành động này được đặt trong một vòng lặp loop để làm đi làm lại cho đến khi chúng ta lấy hết được firmware.

let dl_result = loop {
	let n = response.read(&mut buff).unwrap_or_default();
	total_read_len += n;
	if !got_info {
		match get_firmware_info(&buff[..n]) {
			Ok(info) => log::info!("Firmware to be downloaded: {info:?}"),
			Err(e) => {
				log::error!("Failed to get firmware info from downloaded bytes!");
				break Err(e);
			}
		};
		got_info = true;
	}
	if n > 0 {
		if let Err(e) = work.write(&buff[..n]) {
			log::error!("Failed to write to OTA. {e}");
			break Err(e);
		}
	}
	if total_read_len >= file_size {
		break Ok(());
	}
};

Chúng ta cần nhận thức rằng sự cố mạng có thể xảy ra, nội dung chúng ta nhận được có thể ít hơn sức chứa của buffer, vì vậy khi ghi vào flash, chúng ta không nên mù quáng lấy toàn bộ buffer. Chúng ta chỉ lấy một phần, với &buff[..n], đó là n byte đầu tiên.

Ở các dòng 51 - 57, chúng ta đang kiểm tra phân đoạn đầu tiên, để xem tệp chúng ta đang tải có phải là firmware của ESP32 không. Nếu get_firmware_info thất bại trên phân đoạn này, điều đó có nghĩa là máy chủ đang trả về tệp sai, chúng ta sẽ thôi làm tiếp.

Hàm get_firmware_info được định nghĩa như sau:

fn get_firmware_info(buff: &[u8]) -> Result<FirmwareInfo, EspError> {
    let mut loader = EspFirmwareInfoLoader::new();
    loader.load(buff)?;
    loader.get_info()
}

Ở đây, chúng ta đang thấy một tính năng thú vị của Rust. Vòng lặp loop cũng có thể trả về một giá trị, thông qua break, như là một hàm. Bằng cách đó, chúng ta có thể biết trạng thái của chuỗi hành động trong vòng lặp, là hoàn thành bình thường, hay bị đứt nửa chừng do lỗi.

Tôi đang định nghĩa buff dưới dạng Vec, không phải mảng (array), vì tôi muốn lưu dữ liệu này trong bộ nhớ heap, để ngăn chặn stack overflow. Trong sản phẩm của AgriConnect, hàm này chạy trong một thread (luồng), vì vậy nó không nên sử dụng quá nhiều bộ nhớ stack. Lưu ý rằng đây không phải là lựa chọn duy nhất. Bạn vẫn có thể sử dụng mảng và điều chỉnh kích thước stack của thread. Chỉ là tôi lười biếng thử nghiệm để tìm ra một giá trị phù hợp.

Sau khi hoàn tất việc ghi firmware mới, chúng ta gọi EspOtaUpdate.complete() để chỉ định phân vùng sử dụng cho lần khởi động tiếp theo.

Trước đó, tôi đã nói rằng chúng ta không thể có toàn bộ tệp để kiểm tra tính toàn vẹn trước khi ghi. Nhưng chúng ta có thể sử dụng một hàm hash nào đó biết tiêu thụ dữ liệu ở dạng dòng chảy (stream), và khi đến chunk cuối cùng, chúng ta sẽ có checksum của tệp. Với checksum này, chúng ta có thể kiểm tra tệp không bị hư hỏng trên đường truyền, và hủy OTA trước khi gọi EspOtaUpdate.complete(). Để giữ code ví dụ ngắn gọn, tôi không thêm bước tính toán checksum. Nó được dành cho người đọc như một bài tập.

Trường hợp nâng cao

Trong trường hợp nâng cao, bạn có thể gọi hàm này trong một thread, và có thể bạn muốn theo dõi tiến độ, để báo cáo lên một giao diện web chẳng hạn. Bạn sẽ cần các tính năng sau trong Rust:

Sau đây là chút hé lộ về cách làm này trong sản phẩm của AgriConnect:

use std::thread;
use std::sync::mpsc::channel;

// We are about to create two threads:
// - One to download and flash firmware to board.
// - One to get report from the previous thread and save to AppState.
// It may look complex, but we choose channel to not block the firmware-flashing thread,
// which is the case if using Mutex to modify app-wised variable.
// In the future, we will use RwLock if it is available for ESP32, to not create too many threads.
let (tx, rx) = channel::<FwUpdateProgress>();

// Thread to download and flash firmware.
thread::Builder::new()
    .stack_size(4 * 1024)
    .spawn(move || download_and_update_firmware(&download_url, tx))
    .map_err(|_e| esp_err!(ESP_ERR_NO_MEM))?;
CommonResponse::make_ok_response(request)?;

// Thread to get report and save
thread::spawn(move || save_fw_update_progress_in_thread(rx, fw_update_status, restart_flag));

File thành phẩm của quá trình build cargo build vẫn chưa dùng được cho OTA đâu. Bạn còn phải cắt gọt nó bằng lệnh espflash save-image nữa. Ví dụ trong trường hợp của tôi, nó sẽ là:

$ espflash save-image --chip esp32s3 -s 8mb target/xtensa-esp32s3-espidf/release/ebee /tmp/ebee.bin

Kết bài

Chúng ta đã hoàn thành bài hướng dẫn làm sao để thêm tính năng cập nhật firmware qua mạng bằng Rust cho ESP32. Cảm ơn đội ngũ từ dự án esp-rs đã làm trước những công đoạn tầng thấp để chúng ta có thể viết Rust sướng tay khi lập trình nhúng.