--- title: Lập trình ESP32 với Rust: cập nhật firmware OTA date: 2024-03-30 03:11:56.074474 UTC --- Ở [AgriConnect](https://agriconnect.vn), 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](https://github.com/esp-rs/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](https://docs.esp-rs.org/esp-idf-svc/esp_idf_svc/ota/index.html) 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](/post/2024/3/programming-esp32-with-rust-ota-firmware-update).)_ ## 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](https://github.com/espressif/arduino-esp32/tree/2.0.14/tools/partitions). 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à: ```shell $ 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`](https://docs.esp-rs.org/esp-idf-svc/esp_idf_svc/ota/struct.EspOta.html). - Tạo một đối tượng [`EspOtaUpdate`](https://docs.esp-rs.org/esp-idf-svc/esp_idf_svc/ota/struct.EspOtaUpdate.html) 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: ```rust {lines:true} 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 = 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](/post/2023/8/tan-dung-phong-cach-xu-tri-loi-cua-rust-trong-lap-trinh-web). ## 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: ```rust {lines:true} 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::() + 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`](https://docs.rs/http/latest/http/uri/struct.Uri.html)). 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: ```rust 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](https://docs.esp-rs.org/esp-idf-svc/esp_idf_svc/http/client/index.html) 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: ```rust /// 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: ```rust return Err(EspError::from_infallible::()); ``` 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: ```rust 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à: ```rust 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: ```rust .map_err(|e| e.0) ``` Đó là bởi vì các phương thức này trả về `Result` 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](https://docs.esp-rs.org/esp-idf-svc/esp_idf_svc/io/struct.EspIOError.html): ```rust 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 `http` và [`mime`](https://crates.io/crates/mime), để 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. ```rust {lines:true, start_line: 47} 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: ```rust fn get_firmware_info(buff: &[u8]) -> Result { 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: - Hỗ trợ về [thread](https://doc.rust-lang.org/std/thread/). - Giao tiếp với thread thông qua [multi-producer, single-consumer channel](https://doc.rust-lang.org/std/sync/mpsc/fn.channel.html). Sau đây là chút hé lộ về cách làm này trong sản phẩm của AgriConnect: ```rust 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::(); // 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à: ```shell $ 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` ](https://github.com/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.