Skip to content

Commit a2271d6

Browse files
committed
Refine OTA error handling and documentation for experimental APIs
1 parent 9a55c0c commit a2271d6

7 files changed

Lines changed: 33 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Added
1616

1717
- **OTA MVP** — three new crates for end-to-end firmware update on ESP32-C3 (and ESP32-C6 / ESP32 for the bare-metal stack), aligned with `docs/adr/011-ota-crate-hosting-and-transport.md` and `docs/features/ota-mvp-v1.md`. All public APIs are explicitly experimental for MVP; stabilization is owned by the future `ota-library` feature.
18-
- `ota-pure` (new crate) — platform-independent, `no_std`, host-tested. Surface: `Version` semver parser (`u16` components, `Display`, `Ord`), `StreamingVerifier` (chunk-fed SHA-256 over `sha2 = { default-features = false }`), `bytes_to_hex` / `hex_to_bytes` fixed-size helpers (returns `heapless::String<64>`), `ImageMetadata` sidecar parser (`.bin.sha256` + `.bin.version`), backend-neutral `OtaState` enum (`Idle → Downloading → Verifying → Writing → SwapPending → Booted`) with `next_state()`, and `OtaError` with the 8 MVP variants (`ServerUnreachable`, `DownloadFailed { status: u16 }`, `DownloadTimeout`, `ChecksumMismatch`, `VersionInvalid`, `FlashWriteFailed`, `PartitionNotFound`, `InsufficientSpace`). 37 host unit tests.
19-
- `rustyfarian-esp-idf-ota` (new crate) — ESP-IDF std, blocking, lifted from `rustyfarian-beekeeper/src/ota/`. Surface: `OtaSession::new(config)`, `fetch_and_apply(url, &expected_sha256)`, `mark_valid()`, `rollback()`. Wraps `EspOta` / `EspOtaUpdate` and `EspHttpConnection`; streams download → SHA-256 verify (`StreamingVerifier`) → flash → swap in one pass without holding the full image in RAM. Strips RFC 3986 userinfo from URLs before logging (no credential leakage to `espflash monitor`). HTTPS rejected at MVP scope per ADR 011 (`ota-hardened` will revisit).
20-
- `rustyfarian-esp-hal-ota` (new crate) — bare-metal `no_std`, async-only, built fresh against `esp_bootloader_esp_idf::OtaUpdater` over `esp-storage` and `embassy-net::TcpSocket`. Surface: `EspHalOtaManager::new(config, FLASH<'d>)`, `async fetch_and_apply(socket, url, &expected_sha256)`, `mark_valid()`, `rollback()`. Carries an internal hand-rolled HTTP/1.1 GET parser (per ADR 011 §2): accepts only `HTTP/1.1 200 OK` with exactly one valid `Content-Length`; rejects redirects, `Transfer-Encoding: chunked`/`identity`, missing or duplicate `Content-Length`, non-`1*DIGIT` numeric values (incl. leading `+`/`-`), whitespace before colon, oversized bodies, and short reads. Chip features `esp32c3` (MVP), `esp32c6`, `esp32`; stack features `unstable`, `rt`, `embassy`. Host stub mirrors the wifi-crate pattern (typecheck-only). 29 parser unit tests.
18+
- `ota-pure` (new crate, **experimental API**) — platform-independent, `no_std`, host-tested. Surface: `Version` semver parser (`u16` components, `Display`, `Ord`), `StreamingVerifier` (chunk-fed SHA-256 over `sha2 = { default-features = false }`), `bytes_to_hex` / `hex_to_bytes` fixed-size helpers (returns `heapless::String<64>`), `ImageMetadata` sidecar parser (`.bin.sha256` + `.bin.version`), backend-neutral `OtaState` enum (`Idle → Downloading → Verifying → Writing → SwapPending → Booted`) with `next_state()`, and `OtaError` with the 8 MVP variants (`ServerUnreachable`, `DownloadFailed { status: u16 }`, `DownloadTimeout`, `ChecksumMismatch`, `VersionInvalid`, `FlashWriteFailed`, `PartitionNotFound`, `InsufficientSpace`). `DownloadFailed { status: 0 }` is reserved as a sentinel for protocol-shape rejections from the bare-metal HTTP client. 37 host unit tests.
19+
- `rustyfarian-esp-idf-ota` (new crate, **experimental API**, blocking) — ESP-IDF std, lifted from `rustyfarian-beekeeper/src/ota/`. Surface: `OtaSession::new(config)`, `fetch_and_apply(url, &expected_sha256)`, `mark_valid()`, `rollback()`. Wraps `EspOta` / `EspOtaUpdate` and `EspHttpConnection`; streams download → SHA-256 verify (`StreamingVerifier`) → flash → swap in one pass without holding the full image in RAM. Strips RFC 3986 userinfo from URLs before logging (no credential leakage to `espflash monitor`). HTTPS rejected at MVP scope per ADR 011 (`ota-hardened` will revisit).
20+
- `rustyfarian-esp-hal-ota` (new crate, **experimental API**, async-only) — bare-metal `no_std`, built fresh against `esp_bootloader_esp_idf::OtaUpdater` over `esp-storage` and `embassy-net::TcpSocket`. Surface: `EspHalOtaManager::new(config, FLASH<'d>)`, `async fetch_and_apply(socket, url, &expected_sha256)`, `mark_valid()`, `rollback()`. Carries an internal hand-rolled HTTP/1.1 GET parser (per ADR 011 §2): accepts only `HTTP/1.1 200 OK` with exactly one valid `Content-Length`; rejects redirects, `Transfer-Encoding: chunked`/`identity`, missing or duplicate `Content-Length`, non-`1*DIGIT` numeric values (incl. leading `+`/`-`), whitespace before colon, oversized bodies, and short reads. Chip features `esp32c3` (MVP), `esp32c6`, `esp32`; stack features `unstable`, `rt`, `embassy`. Host stub mirrors the wifi-crate pattern (typecheck-only). 29 parser unit tests.
2121
- Workspace: `sha2 = { version = "0.10", default-features = false }`, `esp-storage = "=0.9.0"`, `embedded-storage = "0.3"` added to `[workspace.dependencies]`; `embedded-svc = "0.29"` declared (aligning with `esp-idf-svc 0.52`).
2222
- Justfile: `check-ota-pure`, `test-ota`, `check-ota-idf`, `check-ota-hal`, `check-ota-hal-embassy`, `test-ota-hal` recipes; `test` aggregate extended.
2323
- CI (`.github/workflows/rust.yml`): host tests for `ota-pure` and `rustyfarian-esp-hal-ota --no-default-features` added to the "Test pure crates" block.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ a pattern common in application development but rare in embedded Rust.
5555
| [`espnow-pure`](crates/espnow-pure) | Platform-independent ESP-NOW types, traits, and validation; `no_std`; unit-testable on host |
5656
| [`rustyfarian-esp-idf-espnow`](crates/rustyfarian-esp-idf-espnow) | ESP-NOW driver for ESP-IDF projects, implementing the `EspNowDriver` trait |
5757
| [`ota-pure`](crates/ota-pure) | Platform-independent OTA primitives — `Version`, streaming SHA-256, sidecar metadata |
58-
| [`rustyfarian-esp-idf-ota`](crates/rustyfarian-esp-idf-ota) | ESP-IDF OTA driver — streaming download, SHA-256 verify, partition swap, rollback |
59-
| [`rustyfarian-esp-hal-ota`](crates/rustyfarian-esp-hal-ota) | Bare-metal OTA driver — async strict HTTP/1.1 over `embassy-net` + `OtaUpdater` (MVP) |
58+
| [`rustyfarian-esp-idf-ota`](crates/rustyfarian-esp-idf-ota) | ESP-IDF OTA driver — **blocking**; streaming download, SHA-256 verify, partition swap, rollback |
59+
| [`rustyfarian-esp-hal-ota`](crates/rustyfarian-esp-hal-ota) | Bare-metal OTA driver — **async-only**; strict HTTP/1.1 over `embassy-net` + `OtaUpdater` (MVP) |
6060

6161
## Usage
6262

crates/ota-pure/src/error.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@ use core::fmt;
99
pub enum OtaError {
1010
/// The update server could not be reached.
1111
ServerUnreachable,
12-
/// The download request returned a non-200 HTTP status.
12+
/// The download request returned a non-200 HTTP status, or the server
13+
/// response failed a strict-protocol check.
14+
///
15+
/// `status == 0` is a sentinel for "protocol-shape rejection" — used by
16+
/// the bare-metal HTTP client when a response is syntactically rejected
17+
/// before a status code is meaningful (e.g. `Transfer-Encoding: chunked`
18+
/// or another unsupported response shape). Any non-zero value is the
19+
/// HTTP status code returned by the server.
1320
DownloadFailed {
14-
/// HTTP status code returned by the server.
21+
/// HTTP status code returned by the server, or `0` for a
22+
/// protocol-shape rejection (see variant docs).
1523
status: u16,
1624
},
1725
/// The download did not complete within the allowed time.

crates/ota-pure/src/verifier.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ pub fn bytes_to_hex(bytes: &[u8; 32]) -> heapless::String<64> {
9090
let mut s = heapless::String::<64>::new();
9191
for &b in bytes {
9292
// Capacity is exactly 64 and we push exactly 64 chars — these cannot fail.
93-
let _ = s.push(HEX_CHARS[(b >> 4) as usize] as char);
94-
let _ = s.push(HEX_CHARS[(b & 0x0f) as usize] as char);
93+
s.push(HEX_CHARS[(b >> 4) as usize] as char).unwrap();
94+
s.push(HEX_CHARS[(b & 0x0f) as usize] as char).unwrap();
9595
}
9696
s
9797
}

crates/rustyfarian-esp-hal-ota/src/manager.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,12 @@ impl<'d> EspHalOtaManager<'d> {
194194
})?
195195
.map_err(|_| OtaError::DownloadTimeout)?;
196196
if n == 0 {
197-
// EOF before Content-Length bytes received.
197+
// EOF before Content-Length bytes received — peer closed the
198+
// socket mid-body. This is a protocol-shape failure (server
199+
// declared a length it did not deliver), not a stall, so use
200+
// the `status: 0` sentinel rather than `DownloadTimeout`.
198201
log::error!("OTA: short read — EOF before {} bytes remaining", remaining);
199-
return Err(OtaError::DownloadTimeout);
202+
return Err(OtaError::DownloadFailed { status: 0 });
200203
}
201204
let chunk = &chunk_buf[..n];
202205
verifier.update(chunk);

crates/rustyfarian-esp-idf-ota/src/downloader.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,14 @@ impl FirmwareDownloader {
8686
let mut buffer = [0u8; DOWNLOAD_BUFFER_SIZE];
8787

8888
loop {
89+
// The embedded-svc `read()` error type collapses connection-reset,
90+
// DNS-mid-stream, and read-timeout into one `IOError`. Map all of
91+
// them to `ServerUnreachable` — most production failures are
92+
// connection-shaped, and a true read-timeout is also a server that
93+
// stopped answering. A future hardened build can differentiate.
8994
let bytes_read = client.read(&mut buffer).map_err(|e| {
9095
log::error!("Read error during firmware download: {:?}", e);
91-
OtaError::DownloadTimeout
96+
OtaError::ServerUnreachable
9297
})?;
9398

9499
if bytes_read == 0 {

crates/rustyfarian-esp-idf-ota/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,13 @@ impl OtaSession {
153153
})?;
154154

155155
// `mark_running_slot_invalid_and_reboot` never returns on success (device reboots).
156-
// It only returns on failure — treat the returned error as FlashWriteFailed.
156+
// It only returns on failure — surface as `FlashWriteFailed` so callers
157+
// can distinguish "no rollback target" (would surface as
158+
// `PartitionNotFound` from `EspOta::new` above) from "rollback path
159+
// failed for some other reason".
157160
let err = ota.mark_running_slot_invalid_and_reboot();
158161
log::error!("Rollback failed (no valid previous slot?): {:?}", err);
159-
Err(OtaError::PartitionNotFound)
162+
Err(OtaError::FlashWriteFailed)
160163
}
161164
}
162165

0 commit comments

Comments
 (0)