419 lines
13 KiB
Rust
419 lines
13 KiB
Rust
//! Over-the-Air (OTA) Update System for RuvLLM ESP32
|
|
//!
|
|
//! Enables wireless firmware updates via WiFi without physical access to the device.
|
|
//!
|
|
//! # Features
|
|
//! - HTTPS firmware download with verification
|
|
//! - SHA256 checksum validation
|
|
//! - Rollback on failed update
|
|
//! - Progress callbacks
|
|
//! - Minimal RAM footprint (streaming update)
|
|
|
|
use core::fmt;
|
|
|
|
/// OTA update configuration
|
|
#[derive(Clone)]
|
|
pub struct OtaConfig {
|
|
/// Firmware server URL
|
|
pub server_url: heapless::String<128>,
|
|
/// Current firmware version
|
|
pub current_version: heapless::String<16>,
|
|
/// WiFi SSID
|
|
pub wifi_ssid: heapless::String<32>,
|
|
/// WiFi password
|
|
pub wifi_password: heapless::String<64>,
|
|
/// Check interval in seconds (0 = manual only)
|
|
pub check_interval_secs: u32,
|
|
/// Enable automatic updates
|
|
pub auto_update: bool,
|
|
}
|
|
|
|
impl Default for OtaConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
server_url: heapless::String::new(),
|
|
current_version: heapless::String::try_from("0.2.1").unwrap_or_default(),
|
|
wifi_ssid: heapless::String::new(),
|
|
wifi_password: heapless::String::new(),
|
|
check_interval_secs: 3600, // 1 hour
|
|
auto_update: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// OTA update state
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum OtaState {
|
|
/// Idle, waiting for update check
|
|
Idle,
|
|
/// Checking for updates
|
|
Checking,
|
|
/// Update available
|
|
UpdateAvailable,
|
|
/// Downloading firmware
|
|
Downloading,
|
|
/// Verifying firmware
|
|
Verifying,
|
|
/// Applying update
|
|
Applying,
|
|
/// Update complete, pending reboot
|
|
Complete,
|
|
/// Update failed
|
|
Failed,
|
|
}
|
|
|
|
impl fmt::Display for OtaState {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
OtaState::Idle => write!(f, "Idle"),
|
|
OtaState::Checking => write!(f, "Checking"),
|
|
OtaState::UpdateAvailable => write!(f, "Update Available"),
|
|
OtaState::Downloading => write!(f, "Downloading"),
|
|
OtaState::Verifying => write!(f, "Verifying"),
|
|
OtaState::Applying => write!(f, "Applying"),
|
|
OtaState::Complete => write!(f, "Complete"),
|
|
OtaState::Failed => write!(f, "Failed"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update information
|
|
#[derive(Clone)]
|
|
pub struct UpdateInfo {
|
|
/// New version string
|
|
pub version: heapless::String<16>,
|
|
/// Firmware size in bytes
|
|
pub size: u32,
|
|
/// SHA256 checksum (hex string)
|
|
pub checksum: heapless::String<64>,
|
|
/// Release notes
|
|
pub notes: heapless::String<256>,
|
|
/// Download URL
|
|
pub download_url: heapless::String<256>,
|
|
}
|
|
|
|
/// OTA update error
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum OtaError {
|
|
/// WiFi connection failed
|
|
WifiError,
|
|
/// HTTP request failed
|
|
HttpError,
|
|
/// Invalid response from server
|
|
InvalidResponse,
|
|
/// Checksum mismatch
|
|
ChecksumMismatch,
|
|
/// Not enough storage space
|
|
InsufficientSpace,
|
|
/// Flash write failed
|
|
FlashError,
|
|
/// Update verification failed
|
|
VerificationFailed,
|
|
/// No update available
|
|
NoUpdate,
|
|
/// Already up to date
|
|
AlreadyUpToDate,
|
|
}
|
|
|
|
impl fmt::Display for OtaError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
OtaError::WifiError => write!(f, "WiFi connection failed"),
|
|
OtaError::HttpError => write!(f, "HTTP request failed"),
|
|
OtaError::InvalidResponse => write!(f, "Invalid server response"),
|
|
OtaError::ChecksumMismatch => write!(f, "Checksum verification failed"),
|
|
OtaError::InsufficientSpace => write!(f, "Not enough storage space"),
|
|
OtaError::FlashError => write!(f, "Flash write error"),
|
|
OtaError::VerificationFailed => write!(f, "Update verification failed"),
|
|
OtaError::NoUpdate => write!(f, "No update available"),
|
|
OtaError::AlreadyUpToDate => write!(f, "Already up to date"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Progress callback type
|
|
pub type ProgressCallback = fn(downloaded: u32, total: u32);
|
|
|
|
/// OTA Update Manager
|
|
pub struct OtaManager {
|
|
config: OtaConfig,
|
|
state: OtaState,
|
|
progress: u32,
|
|
last_error: Option<OtaError>,
|
|
update_info: Option<UpdateInfo>,
|
|
}
|
|
|
|
impl OtaManager {
|
|
/// Create new OTA manager with config
|
|
pub fn new(config: OtaConfig) -> Self {
|
|
Self {
|
|
config,
|
|
state: OtaState::Idle,
|
|
progress: 0,
|
|
last_error: None,
|
|
update_info: None,
|
|
}
|
|
}
|
|
|
|
/// Get current state
|
|
pub fn state(&self) -> OtaState {
|
|
self.state
|
|
}
|
|
|
|
/// Get download progress (0-100)
|
|
pub fn progress(&self) -> u32 {
|
|
self.progress
|
|
}
|
|
|
|
/// Get last error
|
|
pub fn last_error(&self) -> Option<OtaError> {
|
|
self.last_error
|
|
}
|
|
|
|
/// Get available update info
|
|
pub fn update_info(&self) -> Option<&UpdateInfo> {
|
|
self.update_info.as_ref()
|
|
}
|
|
|
|
/// Check for updates (simulation for no_std)
|
|
///
|
|
/// In a real implementation, this would:
|
|
/// 1. Connect to WiFi
|
|
/// 2. Query the update server
|
|
/// 3. Parse the response
|
|
/// 4. Compare versions
|
|
pub fn check_for_update(&mut self) -> Result<bool, OtaError> {
|
|
self.state = OtaState::Checking;
|
|
self.last_error = None;
|
|
|
|
// Simulated version check
|
|
// In real impl: HTTP GET to {server_url}/version.json
|
|
let server_version = "0.2.2"; // Would come from server
|
|
|
|
if self.is_newer_version(server_version) {
|
|
self.update_info = Some(UpdateInfo {
|
|
version: heapless::String::try_from(server_version).unwrap_or_default(),
|
|
size: 512 * 1024, // 512KB
|
|
checksum: heapless::String::try_from(
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
).unwrap_or_default(),
|
|
notes: heapless::String::try_from("Performance improvements and bug fixes").unwrap_or_default(),
|
|
download_url: heapless::String::try_from(
|
|
"https://github.com/ruvnet/ruvector/releases/latest/download/ruvllm-esp32"
|
|
).unwrap_or_default(),
|
|
});
|
|
self.state = OtaState::UpdateAvailable;
|
|
Ok(true)
|
|
} else {
|
|
self.state = OtaState::Idle;
|
|
self.last_error = Some(OtaError::AlreadyUpToDate);
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
/// Compare version strings (simple semver comparison)
|
|
fn is_newer_version(&self, server_version: &str) -> bool {
|
|
let current = self.parse_version(self.config.current_version.as_str());
|
|
let server = self.parse_version(server_version);
|
|
|
|
server > current
|
|
}
|
|
|
|
/// Parse version string to tuple
|
|
fn parse_version(&self, version: &str) -> (u32, u32, u32) {
|
|
let mut parts = version.split('.');
|
|
let major = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let minor = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
let patch = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
|
(major, minor, patch)
|
|
}
|
|
|
|
/// Start firmware download
|
|
///
|
|
/// In real implementation:
|
|
/// 1. Stream download to flash partition
|
|
/// 2. Verify checksum incrementally
|
|
/// 3. Call progress callback
|
|
pub fn download_update(&mut self, _progress_cb: Option<ProgressCallback>) -> Result<(), OtaError> {
|
|
if self.state != OtaState::UpdateAvailable {
|
|
return Err(OtaError::NoUpdate);
|
|
}
|
|
|
|
self.state = OtaState::Downloading;
|
|
self.progress = 0;
|
|
|
|
// Simulated download
|
|
// In real impl: HTTP GET with streaming to flash
|
|
let total_size = self.update_info.as_ref().map(|i| i.size).unwrap_or(0);
|
|
|
|
// Simulate progress
|
|
for i in 0..=100 {
|
|
self.progress = i;
|
|
if let Some(cb) = _progress_cb {
|
|
cb(i * total_size / 100, total_size);
|
|
}
|
|
}
|
|
|
|
self.state = OtaState::Verifying;
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify downloaded firmware
|
|
pub fn verify_update(&mut self) -> Result<(), OtaError> {
|
|
if self.state != OtaState::Verifying {
|
|
return Err(OtaError::VerificationFailed);
|
|
}
|
|
|
|
// In real impl: Calculate SHA256 of downloaded partition
|
|
// Compare with expected checksum
|
|
|
|
// Simulated verification
|
|
self.state = OtaState::Complete;
|
|
Ok(())
|
|
}
|
|
|
|
/// Apply update and reboot
|
|
///
|
|
/// In real implementation:
|
|
/// 1. Set boot partition to new firmware
|
|
/// 2. Reboot device
|
|
pub fn apply_update(&mut self) -> Result<(), OtaError> {
|
|
if self.state != OtaState::Complete {
|
|
return Err(OtaError::VerificationFailed);
|
|
}
|
|
|
|
self.state = OtaState::Applying;
|
|
|
|
// In real impl:
|
|
// esp_ota_set_boot_partition(...)
|
|
// esp_restart()
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Rollback to previous firmware
|
|
pub fn rollback(&mut self) -> Result<(), OtaError> {
|
|
// In real impl:
|
|
// esp_ota_mark_app_invalid_rollback_and_reboot()
|
|
self.state = OtaState::Idle;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get human-readable status
|
|
pub fn status_string(&self) -> &'static str {
|
|
match self.state {
|
|
OtaState::Idle => "Ready",
|
|
OtaState::Checking => "Checking for updates...",
|
|
OtaState::UpdateAvailable => "Update available!",
|
|
OtaState::Downloading => "Downloading update...",
|
|
OtaState::Verifying => "Verifying firmware...",
|
|
OtaState::Applying => "Applying update...",
|
|
OtaState::Complete => "Update complete! Reboot to apply.",
|
|
OtaState::Failed => "Update failed",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// OTA serial command handler
|
|
pub fn handle_ota_command(manager: &mut OtaManager, command: &str) -> heapless::String<256> {
|
|
let mut response = heapless::String::new();
|
|
|
|
let parts: heapless::Vec<&str, 4> = command.split_whitespace().collect();
|
|
let cmd = parts.first().copied().unwrap_or("");
|
|
|
|
match cmd {
|
|
"status" => {
|
|
let _ = core::fmt::write(
|
|
&mut response,
|
|
format_args!("OTA Status: {} ({}%)", manager.status_string(), manager.progress())
|
|
);
|
|
}
|
|
"check" => {
|
|
match manager.check_for_update() {
|
|
Ok(true) => {
|
|
if let Some(info) = manager.update_info() {
|
|
let _ = core::fmt::write(
|
|
&mut response,
|
|
format_args!("Update available: v{} ({}KB)", info.version, info.size / 1024)
|
|
);
|
|
}
|
|
}
|
|
Ok(false) => {
|
|
let _ = response.push_str("Already up to date");
|
|
}
|
|
Err(e) => {
|
|
let _ = core::fmt::write(&mut response, format_args!("Check failed: {}", e));
|
|
}
|
|
}
|
|
}
|
|
"download" => {
|
|
match manager.download_update(None) {
|
|
Ok(()) => {
|
|
let _ = response.push_str("Download complete");
|
|
}
|
|
Err(e) => {
|
|
let _ = core::fmt::write(&mut response, format_args!("Download failed: {}", e));
|
|
}
|
|
}
|
|
}
|
|
"apply" => {
|
|
let _ = manager.verify_update();
|
|
match manager.apply_update() {
|
|
Ok(()) => {
|
|
let _ = response.push_str("Rebooting to apply update...");
|
|
}
|
|
Err(e) => {
|
|
let _ = core::fmt::write(&mut response, format_args!("Apply failed: {}", e));
|
|
}
|
|
}
|
|
}
|
|
"rollback" => {
|
|
match manager.rollback() {
|
|
Ok(()) => {
|
|
let _ = response.push_str("Rolling back to previous firmware...");
|
|
}
|
|
Err(e) => {
|
|
let _ = core::fmt::write(&mut response, format_args!("Rollback failed: {}", e));
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
let _ = response.push_str("OTA commands: status, check, download, apply, rollback");
|
|
}
|
|
}
|
|
|
|
response
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_version_comparison() {
|
|
let config = OtaConfig {
|
|
current_version: heapless::String::try_from("0.2.1").unwrap(),
|
|
..Default::default()
|
|
};
|
|
let manager = OtaManager::new(config);
|
|
|
|
assert!(manager.is_newer_version("0.2.2"));
|
|
assert!(manager.is_newer_version("0.3.0"));
|
|
assert!(manager.is_newer_version("1.0.0"));
|
|
assert!(!manager.is_newer_version("0.2.1"));
|
|
assert!(!manager.is_newer_version("0.2.0"));
|
|
assert!(!manager.is_newer_version("0.1.0"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_transitions() {
|
|
let config = OtaConfig::default();
|
|
let mut manager = OtaManager::new(config);
|
|
|
|
assert_eq!(manager.state(), OtaState::Idle);
|
|
|
|
let _ = manager.check_for_update();
|
|
assert!(matches!(manager.state(), OtaState::UpdateAvailable | OtaState::Idle));
|
|
}
|
|
}
|