Skip to content

Commit b4c3614

Browse files
committed
Add first OTA Crate MVP
1 parent 0c50577 commit b4c3614

27 files changed

Lines changed: 3417 additions & 17 deletions

.github/workflows/rust.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,5 @@ jobs:
5656
cargo test -p wifi-pure --features mock --target "${host_target}"
5757
cargo test -p lora-pure --features mock --target "${host_target}"
5858
cargo test -p espnow-pure --features mock --target "${host_target}"
59+
cargo test -p ota-pure --target "${host_target}"
60+
cargo test -p rustyfarian-esp-hal-ota --no-default-features --target "${host_target}"

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414

1515
### Added
1616

17+
- **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, **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.
21+
- 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`).
22+
- Justfile: `check-ota-pure`, `test-ota`, `check-ota-idf`, `check-ota-hal`, `check-ota-hal-embassy`, `test-ota-hal` recipes; `test` aggregate extended.
23+
- 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.
1724
- Build scripts: `scripts/detect-port.sh` narrows `espflash`'s auto-detect to USB serial devices (`usbmodem*`/`usbserial*` on macOS, `ttyUSB*`/`ttyACM*` on Linux) so paired Bluetooth ports stop hijacking the probe; used by `flash.sh`, `just run`, `just monitor`, and `just erase-flash`. `ESPFLASH_PORT=…` still wins when set explicitly
1825
- `rustyfarian-esp-hal-wifi`: `embassy` Cargo feature + `WiFiManager::init_async()` returning an `AsyncWifiHandle { controller, stack, runner }` wired into an `embassy-net` stack with automatic DHCPv4 (`AsyncWifiHandle::wait_for_ip().await` awaits the first lease). Originally landed alongside a synchronous `WiFiManager::init` path that drove `smoltcp` directly; that sync path was removed later in this same release cycle when the stack moved to `esp-radio 0.18` (see the breaking-change entry below). The `embassy` feature is now the only supported Wi-Fi path on bare-metal — see `docs/features/embassy-feature-flag-v1.md` and `docs/features/wifi-manager-async-v1.md`
1926
- `rustyfarian-esp-hal-wifi`: `hal_c3_connect_async` example — first async bare-metal Wi-Fi demo on ESP32-C3, uses `#[esp_rtos::main]` with two spawned tasks (`wifi_task` for association + reconnection, `net_task` for the embassy-net runner), prints the DHCP-assigned IP and idles asynchronously (see `docs/features/hal-c3-connect-async-example-v1.md`)

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ rustyfarian-network-pure = { path = "crates/rustyfarian-network-pure" }
1414
lora-pure = { path = "crates/lora-pure" }
1515
wifi-pure = { path = "crates/wifi-pure" }
1616
espnow-pure = { path = "crates/espnow-pure" }
17+
ota-pure = { path = "crates/ota-pure" }
1718

1819
# External dependencies
1920
anyhow = "1.0"
@@ -26,9 +27,11 @@ embuild = "0.33"
2627
esp-idf-svc = "0.52"
2728
esp-idf-hal = "0.46"
2829
embedded-hal = "1"
30+
embedded-svc = "0.29"
2931

3032
# LoRa / LoRaWAN dependencies
3133
heapless = "0.9"
34+
sha2 = { version = "0.10", default-features = false }
3235
lorawan-device = { version = "0.12", default-features = false, features = ["default-crypto", "region-eu868", "region-us915"] }
3336
lora-modulation = "0.1"
3437
sx126x = "0.3"
@@ -42,6 +45,8 @@ esp-radio = { version = "=0.18.0", default-features = false }
4245
esp-rtos = { version = "=0.3.0", default-features = false }
4346
esp-alloc = { version = "=0.10.0" }
4447
esp-bootloader-esp-idf = { version = "=0.5.0", default-features = false }
48+
esp-storage = { version = "=0.9.0", default-features = false }
49+
embedded-storage = { version = "0.3" }
4550
esp-println = { version = "=0.17.0", default-features = false }
4651
# `smoltcp` was dropped from the workspace dependency table when `esp-radio 0.18`
4752
# removed its `smoltcp` feature in favour of `embassy-net-driver`; no member crate

README.md

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![cargo clippy](https://github.com/datenkollektiv/rustyfarian-network/actions/workflows/clippy.yml/badge.svg)](https://github.com/datenkollektiv/rustyfarian-network/actions/workflows/clippy.yml)
88
[![cargo audit](https://github.com/datenkollektiv/rustyfarian-network/actions/workflows/audit.yml/badge.svg)](https://github.com/datenkollektiv/rustyfarian-network/actions/workflows/audit.yml)
99

10-
Wi-Fi, MQTT, LoRa, and ESP-NOW networking libraries for ESP32 projects.
10+
Wi-Fi, MQTT, LoRa, ESP-NOW, and OTA support libraries for ESP32 projects.
1111

1212
> Note: Large parts of this library (and documentation) were developed with the assistance of AI tools.
1313
> All generated code has been reviewed and curated by the maintainer.
@@ -23,7 +23,8 @@ Wi-Fi, MQTT, LoRa, and ESP-NOW networking libraries for ESP32 projects.
2323
- A growing platform-independent layer (`rustyfarian-network-pure`) that can be unit-tested on the host
2424
- Minimal friction: a few lines of `Cargo.toml` and no surprises
2525

26-
**Out of scope:** Application-layer protocols (HTTP, CoAP, WebSocket) and provisioning/SoftAP flows.
26+
**Out of scope:** General-purpose application-layer clients (HTTP, CoAP, WebSocket) and provisioning/SoftAP flows.
27+
The OTA crates (`rustyfarian-esp-idf-ota`, `rustyfarian-esp-hal-ota`) carry their own internal HTTP/1.1 GET clients for firmware download, but these are implementation details and not published as reusable workspace HTTP APIs.
2728

2829
*Full vision, success signals, and open questions: [VISION.md](./VISION.md)*
2930

@@ -41,18 +42,21 @@ a pattern common in application development but rare in embedded Rust.
4142

4243
## Crates
4344

44-
| Crate | Description |
45-
|:--------------------------------------------------------------|:------------------------------------------------------------------------------------------|
46-
| [`rustyfarian-network-pure`](crates/rustyfarian-network-pure) | Platform-independent primitives — validation, timing math; unit-testable on the host |
47-
| [`wifi-pure`](crates/wifi-pure) | Platform-independent Wi-Fi types, traits, and validation; `no_std`; unit-testable on host |
48-
| [`rustyfarian-esp-idf-wifi`](crates/rustyfarian-esp-idf-wifi) | Wi-Fi connection manager with LED status feedback |
49-
| [`rustyfarian-esp-hal-wifi`](crates/rustyfarian-esp-hal-wifi) | Wi-Fi driver stub for bare-metal `esp-hal` targets; full implementation in progress |
50-
| [`rustyfarian-esp-idf-mqtt`](crates/rustyfarian-esp-idf-mqtt) | MQTT client with automatic reconnection and graceful shutdown |
51-
| [`lora-pure`](crates/lora-pure) | Platform-independent LoRa/LoRaWAN types and traits; `no_std`; unit-testable on host |
52-
| [`rustyfarian-esp-idf-lora`](crates/rustyfarian-esp-idf-lora) | LoRa radio driver (SX1262) and LoRaWAN adapter for ESP-IDF targets |
53-
| [`rustyfarian-esp-hal-lora`](crates/rustyfarian-esp-hal-lora) | LoRa radio stub for bare-metal `esp-hal` targets; hardware driver in progress |
45+
| Crate | Description |
46+
|:------------------------------------------------------------------|:--------------------------------------------------------------------------------------------|
47+
| [`rustyfarian-network-pure`](crates/rustyfarian-network-pure) | Platform-independent primitives — validation, timing math; unit-testable on the host |
48+
| [`wifi-pure`](crates/wifi-pure) | Platform-independent Wi-Fi types, traits, and validation; `no_std`; unit-testable on host |
49+
| [`rustyfarian-esp-idf-wifi`](crates/rustyfarian-esp-idf-wifi) | Wi-Fi connection manager with LED status feedback |
50+
| [`rustyfarian-esp-hal-wifi`](crates/rustyfarian-esp-hal-wifi) | Wi-Fi driver stub for bare-metal `esp-hal` targets; full implementation in progress |
51+
| [`rustyfarian-esp-idf-mqtt`](crates/rustyfarian-esp-idf-mqtt) | MQTT client with automatic reconnection and graceful shutdown |
52+
| [`lora-pure`](crates/lora-pure) | Platform-independent LoRa/LoRaWAN types and traits; `no_std`; unit-testable on host |
53+
| [`rustyfarian-esp-idf-lora`](crates/rustyfarian-esp-idf-lora) | LoRa radio driver (SX1262) and LoRaWAN adapter for ESP-IDF targets |
54+
| [`rustyfarian-esp-hal-lora`](crates/rustyfarian-esp-hal-lora) | LoRa radio stub for bare-metal `esp-hal` targets; hardware driver in progress |
5455
| [`espnow-pure`](crates/espnow-pure) | Platform-independent ESP-NOW types, traits, and validation; `no_std`; unit-testable on host |
5556
| [`rustyfarian-esp-idf-espnow`](crates/rustyfarian-esp-idf-espnow) | ESP-NOW driver for ESP-IDF projects, implementing the `EspNowDriver` trait |
57+
| [`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 — **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) |
5660

5761
## Usage
5862

VISION.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Any ESP32-IDF project can add Wi-Fi and MQTT in minutes, with confidence.
2020
- **An `esp-hal` bare-metal tier** — dedicated `rustyfarian-esp-hal-*` crates provide bare-metal alternatives alongside the existing ESP-IDF path.
2121
This generalises the earlier LoRa-only `esp-hal` goal into a workspace-wide pattern: separate crates per HAL tier with shared `*-pure` crates for platform-independent types and traits (see [ADR 005](docs/adr/005-crate-naming-for-dual-hal-drivers.md)).
2222
Active: `rustyfarian-esp-hal-lora` (LoRa radio driver) and `rustyfarian-esp-hal-wifi` (Wi-Fi via `esp-wifi 0.14.0`, in progress).
23+
- **OTA as firmware-update plumbing** — OTA support may live in this workspace when it reuses the same Wi-Fi, bootloader, partition-table, and dual-HAL foundations as the networking crates.
2324

2425
## Target Beneficiaries
2526

@@ -28,8 +29,8 @@ but with an API clean enough that any ESP32-IDF project can adopt it with confid
2829

2930
## Non-Goals
3031

31-
- **Application-layer protocols** — HTTP, CoAP, WebSocket, and similar are out of scope;
32-
this library stops at Wi-Fi association and MQTT pub/sub.
32+
- **General-purpose application-layer clients** — HTTP, CoAP, WebSocket, and similar reusable clients are out of scope.
33+
Feature-specific private transports may exist behind crate APIs, such as OTA fetching, but are not exported as protocol libraries.
3334
- **Provisioning / SoftAP mode** — no captive portal, BLE provisioning, or Wi-Fi setup flows.
3435
- **Full `no_std` / `esp-hal` MQTT** — MQTT over bare-metal Wi-Fi (`rustyfarian-esp-hal-mqtt`) is a long-term goal but not an active workstream; Wi-Fi association is the current `esp-hal` frontier.
3536

@@ -54,3 +55,5 @@ _(none at this time)_
5455
- 2026-03-12 — `esp-hal` Wi-Fi promoted from non-goal to active goal; LoRa path blocked on hardware.
5556
`rustyfarian-esp-hal-wifi` added to long-term goals; non-goal narrowed to `esp-hal` MQTT only.
5657
The `esp-hal` goal was generalised from LoRa-only to a workspace-wide dual-HAL pattern (ADR 005).
58+
- 2026-04-29 — OTA accepted as firmware-update plumbing in this workspace, with private transports only.
59+
General-purpose HTTP clients remain a non-goal (ADR 011).

crates/ota-pure/Cargo.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "ota-pure"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
description = "Platform-independent OTA primitives — version parsing, streaming SHA-256, metadata"
9+
keywords = ["ota", "embedded", "no-std"]
10+
categories = ["embedded", "no-std"]
11+
12+
[dependencies]
13+
sha2 = { workspace = true, default-features = false }
14+
heapless = { workspace = true }

crates/ota-pure/src/error.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
//! OTA error types.
2+
3+
use core::fmt;
4+
5+
/// Experimental: API may change before 1.0.
6+
///
7+
/// Errors that can occur during OTA updates.
8+
#[derive(Debug, Clone, PartialEq, Eq)]
9+
pub enum OtaError {
10+
/// The update server could not be reached.
11+
ServerUnreachable,
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.
20+
DownloadFailed {
21+
/// HTTP status code returned by the server, or `0` for a
22+
/// protocol-shape rejection (see variant docs).
23+
status: u16,
24+
},
25+
/// The download did not complete within the allowed time.
26+
DownloadTimeout,
27+
/// The computed SHA-256 digest does not match the expected value.
28+
ChecksumMismatch,
29+
/// The version string could not be parsed.
30+
VersionInvalid,
31+
/// Writing to flash failed.
32+
FlashWriteFailed,
33+
/// The OTA partition could not be located.
34+
PartitionNotFound,
35+
/// There is not enough flash space for the new image.
36+
InsufficientSpace,
37+
}
38+
39+
impl fmt::Display for OtaError {
40+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41+
match self {
42+
OtaError::ServerUnreachable => write!(f, "Update server unreachable"),
43+
OtaError::DownloadFailed { status } => {
44+
write!(f, "Download failed with status {status}")
45+
}
46+
OtaError::DownloadTimeout => write!(f, "Download timeout"),
47+
OtaError::ChecksumMismatch => write!(f, "Firmware checksum mismatch"),
48+
OtaError::VersionInvalid => write!(f, "Firmware version invalid"),
49+
OtaError::FlashWriteFailed => write!(f, "Flash write failed"),
50+
OtaError::PartitionNotFound => write!(f, "OTA partition not found"),
51+
OtaError::InsufficientSpace => write!(f, "Insufficient flash space"),
52+
}
53+
}
54+
}

crates/ota-pure/src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#![no_std]
2+
//! Platform-independent OTA primitives — Version parsing, streaming SHA-256,
3+
//! sidecar metadata, backend-neutral state machine.
4+
//!
5+
//! All public APIs are experimental.
6+
7+
pub mod error;
8+
pub mod metadata;
9+
pub mod state;
10+
pub mod verifier;
11+
pub mod version;
12+
13+
pub use error::OtaError;
14+
pub use metadata::ImageMetadata;
15+
pub use state::OtaState;
16+
pub use verifier::{bytes_to_hex, hex_to_bytes, StreamingVerifier};
17+
pub use version::Version;

crates/ota-pure/src/metadata.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! Firmware image sidecar metadata parser.
2+
3+
use crate::error::OtaError;
4+
use crate::verifier::hex_to_bytes;
5+
use crate::version::Version;
6+
7+
/// Experimental: API may change before 1.0.
8+
///
9+
/// Sidecar metadata for a firmware image.
10+
///
11+
/// Parsed from the `.bin.sha256` (64-char hex digest) and `.bin.version`
12+
/// (semver string) sidecar files that accompany each firmware image.
13+
#[derive(Debug, Clone, PartialEq, Eq)]
14+
pub struct ImageMetadata {
15+
/// Expected SHA-256 digest of the firmware image.
16+
pub sha256: [u8; 32],
17+
/// Declared firmware version.
18+
pub version: Version,
19+
}
20+
21+
impl ImageMetadata {
22+
/// Experimental: API may change before 1.0.
23+
///
24+
/// Parse metadata from raw sidecar strings.
25+
///
26+
/// `sha256_hex` must be a 64-character lowercase (or uppercase) hex string.
27+
/// `version_str` must be a `"MAJOR.MINOR.PATCH"` semver string.
28+
/// Both inputs are trimmed of leading/trailing whitespace before parsing.
29+
///
30+
/// Returns `Err(OtaError::ChecksumMismatch)` for a malformed digest, or
31+
/// `Err(OtaError::VersionInvalid)` for a malformed version string.
32+
pub fn parse(sha256_hex: &str, version_str: &str) -> Result<Self, OtaError> {
33+
let sha256 = hex_to_bytes(sha256_hex.trim())?;
34+
let version = Version::parse(version_str.trim())?;
35+
Ok(Self { sha256, version })
36+
}
37+
}
38+
39+
#[cfg(test)]
40+
mod tests {
41+
use super::*;
42+
43+
const HELLO_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
44+
45+
#[test]
46+
fn parse_valid_metadata() {
47+
let m = ImageMetadata::parse(HELLO_HASH, "1.2.3").unwrap();
48+
assert_eq!(m.version, Version::new(1, 2, 3));
49+
assert_eq!(m.sha256[0], 0x2c);
50+
}
51+
52+
#[test]
53+
fn parse_trims_whitespace() {
54+
// Build padded hex without heap allocation.
55+
use core::fmt::Write as _;
56+
let mut padded = heapless::String::<68>::new();
57+
write!(padded, " {HELLO_HASH} ").unwrap();
58+
let m = ImageMetadata::parse(padded.as_str(), " 0.1.8\n").unwrap();
59+
assert_eq!(m.version, Version::new(0, 1, 8));
60+
}
61+
62+
#[test]
63+
fn parse_invalid_hash_returns_checksum_error() {
64+
let result = ImageMetadata::parse("not-a-hash", "1.0.0");
65+
assert_eq!(result, Err(OtaError::ChecksumMismatch));
66+
}
67+
68+
#[test]
69+
fn parse_invalid_version_returns_version_error() {
70+
let result = ImageMetadata::parse(HELLO_HASH, "bad-version");
71+
assert_eq!(result, Err(OtaError::VersionInvalid));
72+
}
73+
}

0 commit comments

Comments
 (0)