fix ota abort/invalid switching

This commit is contained in:
2025-10-06 02:43:37 +02:00
parent 894be7c373
commit a3cdd92af8
11 changed files with 224 additions and 192 deletions

12
rust/all.sh Executable file
View File

@@ -0,0 +1,12 @@
rm ./src/webserver/index.html.gz
rm ./src/webserver/bundle.js.gz
set -e
cd ./src_webpack/
npx webpack build
cp index.html.gz ../src/webserver/index.html.gz
cp bundle.js.gz ../src/webserver/bundle.js.gz
cd ../
cargo build --release
espflash save-image --bootloader bootloader.bin --partition-table partitions.csv --chip esp32c6 target/riscv32imac-unknown-none-elf/release/plant-ctrl2 image.bin
espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv target/riscv32imac-unknown-none-elf/release/plant-ctrl2

View File

@@ -7,5 +7,5 @@ cp index.html.gz ../src/webserver/index.html.gz
cp bundle.js.gz ../src/webserver/bundle.js.gz
cd ../
cargo build
cargo build --release
espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv target/riscv32imac-unknown-none-elf/release/plant-ctrl2

View File

@@ -65,6 +65,9 @@ pub enum FatError {
CanBusError {
error: EspTwaiError,
},
SNTPError {
error: sntpc::Error,
},
}
pub type FatResult<T> = Result<T, FatError>;
@@ -96,6 +99,7 @@ impl fmt::Display for FatError {
FatError::CanBusError { error } => {
write!(f, "CanBusError {:?}", error)
}
FatError::SNTPError { error } => write!(f, "SNTPError {:?}", error),
}
}
}
@@ -302,7 +306,13 @@ impl From<nb::Error<EspTwaiError>> for FatError {
impl From<NorFlashErrorKind> for FatError {
fn from(value: NorFlashErrorKind) -> Self {
FatError::String {
error: value.to_string()
error: value.to_string(),
}
}
}
impl From<sntpc::Error> for FatError {
fn from(value: sntpc::Error) -> Self {
FatError::SNTPError { error: value }
}
}

View File

@@ -1,6 +1,6 @@
use crate::bail;
use crate::config::{NetworkConfig, PlantControllerConfig};
use crate::hal::{get_next_slot, PLANT_COUNT, TIME_ACCESS};
use crate::hal::{get_current_slot_and_fix_ota_data, PLANT_COUNT, TIME_ACCESS};
use crate::log::{LogMessage, LOG_ACCESS};
use chrono::{DateTime, Utc};
use serde::Serialize;
@@ -19,10 +19,10 @@ use embassy_net::{DhcpConfig, Ipv4Cidr, Runner, Stack, StackResources, StaticCon
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::{Mutex, MutexGuard};
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer};
use embassy_time::{Duration, Timer, WithTimeout};
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash};
use esp_bootloader_esp_idf::ota::OtaImageState::Valid;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState, Slot};
use esp_bootloader_esp_idf::partitions::FlashRegion;
use esp_hal::gpio::{Input, RtcPinWithResistors};
use esp_hal::rng::Rng;
@@ -128,7 +128,10 @@ pub struct Esp<'a> {
pub wake_gpio1: esp_hal::peripherals::GPIO1<'static>,
pub ota: Ota<'static, FlashStorage>,
pub ota_next: &'static mut FlashRegion<'static, FlashStorage>,
pub ota_target: &'static mut FlashRegion<'static, FlashStorage>,
pub current: Slot,
pub slot0_state: OtaImageState,
pub slot1_state: OtaImageState,
}
// SAFETY: On this target we never move Esp across OS threads; the firmware runs single-core
@@ -213,43 +216,15 @@ impl Esp<'_> {
Ok((buf, read))
}
pub(crate) fn get_current_ota_slot(&mut self) -> String {
match get_next_slot(&mut self.ota) {
Ok(slot) => {
format!("{:?}", slot.next())
}
Err(err) => {
format!("{:?}", err)
}
}
}
pub(crate) fn get_ota_state(&mut self) -> String {
match self.ota.current_ota_state() {
Ok(state) => {
format!("{:?}", state)
}
Err(err) => {
format!("{:?}", err)
}
}
}
pub(crate) async fn write_ota(&mut self, offset: u32, buf: &[u8]) -> Result<(), FatError> {
if self.ota.current_ota_state() == Ok(OtaImageState::Invalid) {
bail!("Invalid OTA state, refusing ota write")
}
if self.ota.current_ota_state() == Ok(OtaImageState::Undefined) {
bail!("Invalid OTA state, refusing ota write")
}
let _ = check_erase(self.ota_next, offset, offset + 4096);
self.ota_next.erase(offset, offset + 4096)?;
let _ = check_erase(self.ota_target, offset, offset + 4096);
self.ota_target.erase(offset, offset + 4096)?;
let mut temp = vec![0; buf.len()];
let read_back = temp.as_mut_slice();
//change to nor flash, align writes!
self.ota_next.write(offset, buf)?;
self.ota_next.read(offset, read_back)?;
self.ota_target.write(offset, buf)?;
self.ota_target.read(offset, read_back)?;
if buf != read_back {
info!("Expected {:?} but got {:?}", buf, read_back);
bail!(
@@ -261,23 +236,16 @@ impl Esp<'_> {
}
pub(crate) async fn finalize_ota(&mut self) -> Result<(), FatError> {
if self.ota.current_ota_state() == Ok(OtaImageState::Invalid) {
bail!("Invalid OTA state, refusing ota write")
}
if self.ota.current_ota_state() == Ok(OtaImageState::Undefined) {
bail!("Invalid OTA state, refusing ota write")
}
let current_state = self.ota.current_ota_state()?;
info!("current state {:?}", current_state);
let next_slot = get_next_slot(&mut self.ota)?;
info!("current slot {:?}", next_slot.next());
if current_state == OtaImageState::PendingVerify {
info!("verifying ota image from pending");
let current = self.ota.current_slot()?;
if self.ota.current_ota_state()? != OtaImageState::Valid {
info!(
"Validating current slot {:?} as it was able to ota",
current
);
self.ota.set_current_ota_state(Valid)?;
}
self.ota.set_current_slot(next_slot)?;
self.ota.set_current_slot(current.next())?;
info!("switched slot");
self.ota.set_current_ota_state(OtaImageState::New)?;
info!("switched state for new partition");
@@ -327,16 +295,19 @@ impl Esp<'_> {
let mut counter = 0;
loop {
let addr: IpAddr = ntp_addrs[0].into();
let result = get_time(SocketAddr::from((addr, 123)), &socket, context).await;
let timeout = get_time(SocketAddr::from((addr, 123)), &socket, context)
.with_timeout(Duration::from_millis((_max_wait_ms / 10) as u64))
.await;
match result {
Ok(time) => {
match timeout {
Ok(result) => {
let time = result?;
info!("Time: {:?}", time);
return DateTime::from_timestamp(time.seconds as i64, 0)
.context("Could not convert Sntp result");
}
Err(e) => {
warn!("Error: {:?}", e);
Err(err) => {
warn!("sntp timeout, retry: {:?}", err);
counter += 1;
if counter > 10 {
bail!("Failed to get time from NTP server");

View File

@@ -9,7 +9,6 @@ mod v3_shift_register;
mod v4_hal;
mod v4_sensor;
mod water;
use crate::alloc::string::ToString;
use crate::hal::rtc::{DS3231Module, RTCModuleInteraction};
use esp_hal::peripherals::Peripherals;
@@ -44,6 +43,7 @@ use esp_hal::peripherals::GPIO8;
use esp_hal::peripherals::TWAI0;
use crate::{
bail,
config::{BatteryBoardVersion, BoardVersion, PlantControllerConfig},
hal::{
battery::{BatteryInteraction, NoBatteryMonitor},
@@ -83,9 +83,11 @@ use crate::hal::water::TankSensor;
use crate::log::LOG_ACCESS;
use embassy_sync::mutex::Mutex;
use embassy_sync::once_lock::OnceLock;
use embedded_storage::nor_flash::ReadNorFlash;
use esp_alloc as _;
use esp_backtrace as _;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState, Slot};
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_bootloader_esp_idf::ota::{Slot as ota_slot, Slot};
use esp_hal::delay::Delay;
use esp_hal::i2c::master::{BusTimeout, Config, I2c};
use esp_hal::pcnt::unit::Unit;
@@ -101,12 +103,15 @@ use esp_wifi::{init, EspWifiController};
use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem};
use littlefs2::object_safe::DynStorage;
use log::{error, info, warn};
use portable_atomic::AtomicBool;
pub static TIME_ACCESS: OnceLock<Mutex<CriticalSectionRawMutex, Rtc>> = OnceLock::new();
//Only support for 8 right now!
pub const PLANT_COUNT: usize = 8;
pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false);
const TANK_MULTI_SAMPLE: usize = 11;
pub static I2C_DRIVER: OnceLock<
embassy_sync::blocking_mutex::Mutex<CriticalSectionRawMutex, RefCell<I2c<Blocking>>>,
@@ -148,6 +153,9 @@ pub trait BoardInteraction<'a> {
async fn get_mptt_current(&mut self) -> Result<Current, FatError>;
async fn progress(&mut self, counter: u32) {
// Indicate progress is active to suppress default wait_infinity blinking
crate::hal::PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed);
let current = counter % PLANT_COUNT as u32;
for led in 0..PLANT_COUNT {
if let Err(err) = self.fault(led, current == led as u32).await {
@@ -165,6 +173,9 @@ pub trait BoardInteraction<'a> {
}
}
let _ = self.general_fault(false).await;
// Reset progress active flag so wait_infinity can resume blinking
crate::hal::PROGRESS_ACTIVE.store(false, core::sync::atomic::Ordering::Relaxed);
}
}
@@ -303,10 +314,6 @@ impl PlantHal {
let pt =
esp_bootloader_esp_idf::partitions::read_partition_table(storage_ota, tablebuffer)?;
// List all partitions - this is just FYI
for i in 0..pt.len() {
info!("{:?}", pt.get_partition(i));
}
let ota_data = mk_static!(
PartitionEntry,
pt.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data(
@@ -320,13 +327,18 @@ impl PlantHal {
ota_data.as_embedded_storage(storage_ota)
);
let state_0 = ota_state(ota_slot::Slot0, ota_data);
let state_1 = ota_state(ota_slot::Slot1, ota_data);
let mut ota = Ota::new(ota_data)?;
let running = get_current_slot_and_fix_ota_data(&mut ota, state_0, state_1)?;
let target = running.next();
let state = ota.current_ota_state().unwrap_or_default();
info!("Current OTA state: {:?}", state);
let next_slot = get_next_slot(&mut ota)?;
info!("Next OTA slot: {:?}", next_slot);
let ota_next = match next_slot {
info!("Currently running OTA slot: {:?}", running);
info!("Slot0 state: {:?}", state_0);
info!("Slot1 state: {:?}", state_1);
//obtain current_state and next_state here!
let ota_target = match target {
Slot::None => {
panic!("No OTA slot active?");
}
@@ -342,11 +354,11 @@ impl PlantHal {
.context("Partition table invalid no ota1")?,
};
let ota_next = mk_static!(PartitionEntry, ota_next);
let ota_target = mk_static!(PartitionEntry, ota_target);
let storage_ota = mk_static!(FlashStorage, FlashStorage::new());
let ota_next = mk_static!(
let ota_target = mk_static!(
FlashRegion<FlashStorage>,
ota_next.as_embedded_storage(storage_ota)
ota_target.as_embedded_storage(storage_ota)
);
let data_partition = pt
@@ -391,7 +403,10 @@ impl PlantHal {
boot_button,
wake_gpio1,
ota,
ota_next,
ota_target,
current: running,
slot0_state: state_0,
slot1_state: state_1,
};
//init,reset rtc memory depending on cause
@@ -573,29 +588,75 @@ impl PlantHal {
}
}
fn get_next_slot(ota: &mut Ota<FlashStorage>) -> Result<Slot, FatError> {
let next_slot = {
fn ota_state(slot: ota_slot, ota_data: &mut FlashRegion<FlashStorage>) -> OtaImageState {
// Read and log OTA states for both slots before constructing Ota
// Each OTA select entry is 32 bytes: [seq:4][label:20][state:4][crc:4]
// Offsets within the OTA data partition: slot0 @ 0x0000, slot1 @ 0x1000
if slot == ota_slot::None {
return OtaImageState::Undefined;
}
let mut slot_buf = [0u8; 32];
if slot == ota_slot::Slot0 {
let _ = ota_data.read(0x0000, &mut slot_buf);
} else {
let _ = ota_data.read(0x1000, &mut slot_buf);
}
let raw_state = u32::from_le_bytes(slot_buf[24..28].try_into().unwrap_or([0xff; 4]));
let state0 = OtaImageState::try_from(raw_state).unwrap_or(OtaImageState::Undefined);
state0
}
fn get_current_slot_and_fix_ota_data(
ota: &mut Ota<FlashStorage>,
state0: OtaImageState,
state1: OtaImageState,
) -> Result<ota_slot, FatError> {
let state = ota.current_ota_state().unwrap_or_default();
let swap = match state {
OtaImageState::Invalid => true,
OtaImageState::Aborted => true,
OtaImageState::Undefined => {
info!("Undefined image in current slot, bootloader wrong?");
false
}
_ => false,
};
let current = ota.current_slot()?;
match state {
OtaImageState::New => current.next(),
OtaImageState::PendingVerify => current.next(),
OtaImageState::Valid => current.next(),
if swap {
let other = match current {
ota_slot::Slot0 => state1,
ota_slot::Slot1 => state0,
_ => OtaImageState::Invalid,
};
match other {
OtaImageState::Invalid => {
//we actually booted other slot, than partition table assumes
current
bail!(
"cannot recover slot, as both slots in invalid state {:?} {:?} {:?}",
current,
state0,
state1
);
}
OtaImageState::Aborted => {
bail!(
"cannot recover slot, as both slots in invalid state {:?} {:?} {:?}",
current,
state0,
state1
);
}
_ => {}
}
info!(
"Current slot has state {:?} other state has {:?} swapping",
state, other
);
ota.set_current_slot(current.next())?;
//we actually booted other slot, than partition table assumes
current
}
OtaImageState::Undefined => {
//missing bootloader?
current.next()
}
}
return Ok(ota.current_slot()?);
};
Ok(next_slot)
Ok(current)
}
pub async fn esp_time() -> DateTime<Utc> {

View File

@@ -17,6 +17,7 @@ use crate::config::{NetworkConfig, PlantConfig};
use crate::fat_error::FatResult;
use crate::hal::esp::MQTT_STAY_ALIVE;
use crate::hal::{esp_time, TIME_ACCESS};
use crate::hal::PROGRESS_ACTIVE;
use crate::log::{log, LOG_ACCESS};
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH};
use crate::webserver::http_server;
@@ -822,18 +823,9 @@ async fn publish_firmware_info(
let esp = board.board_hal.get_esp();
let _ = esp.mqtt_publish("/firmware/address", ip_address).await;
let _ = esp
.mqtt_publish("/firmware/githash", &version.git_hash)
.await;
let _ = esp
.mqtt_publish("/firmware/buildtime", &version.build_time)
.mqtt_publish("/firmware/state", format!("{:?}", &version).as_str())
.await;
let _ = esp.mqtt_publish("/firmware/last_online", timezone_time);
let state = esp.get_ota_state();
let _ = esp.mqtt_publish("/firmware/ota_state", &state).await;
let slot = esp.get_current_ota_slot();
let _ = esp
.mqtt_publish("/firmware/ota_slot", &format!("slot{slot}"))
.await;
let _ = esp.mqtt_publish("/state", "online").await;
}
macro_rules! mk_static {
@@ -995,6 +987,8 @@ async fn wait_infinity(
let mut board = BOARD_ACCESS.get().await.lock().await;
update_charge_indicator(&mut board).await;
// Skip default blink code when a progress display is active
if !PROGRESS_ACTIVE.load(Ordering::Relaxed) {
match wait_type {
WaitType::MissingConfig => {
// Keep existing behavior: circular filling pattern
@@ -1021,10 +1015,14 @@ async fn wait_infinity(
}
board.board_hal.general_fault(true).await;
}
}
Timer::after_millis(delay).await;
{
let mut board = BOARD_ACCESS.get().await.lock().await;
// Skip clearing LEDs when progress is active to avoid interrupting the progress display
if !PROGRESS_ACTIVE.load(Ordering::Relaxed) {
board.board_hal.general_fault(false).await;
// Clear all LEDs
@@ -1032,6 +1030,7 @@ async fn wait_infinity(
let _ = board.board_hal.fault(i, false).await;
}
}
}
Timer::after_millis(delay).await;
@@ -1096,14 +1095,12 @@ async fn get_version(
let hash = &env!("VERGEN_GIT_SHA")[0..8];
let board = board.board_hal.get_esp();
let ota_slot = board.get_current_ota_slot();
let ota_state = board.get_ota_state();
VersionInfo {
git_hash: branch + "@" + hash,
build_time: env!("VERGEN_BUILD_TIMESTAMP").to_owned(),
partition: ota_slot,
ota_state,
current: format!("{:?}", board.current),
slot0_state: format!("{:?}", board.slot0_state),
slot1_state: format!("{:?}", board.slot1_state),
}
}
@@ -1111,6 +1108,7 @@ async fn get_version(
struct VersionInfo {
git_hash: String,
build_time: String,
partition: String,
ota_state: String,
current: String,
slot0_state: String,
slot1_state: String,
}

View File

@@ -240,34 +240,6 @@ pub async fn http_server(reboot_now: Arc<AtomicBool>, stack: Stack<'static>) {
info!("Webserver started and waiting for connections");
//TODO https if mbed_esp lands
// server
// .fn_handler("/ota", Method::Post, |request| {
// handle_error_to500(request, ota)
// })
// .unwrap();
// server
// .fn_handler("/ota", Method::Options, |request| {
// cors_response(request, 200, "")
// })
// .unwrap();
// let reboot_now_for_reboot = reboot_now.clone();
// server
// .fn_handler("/reboot", Method::Post, move |_| {
// BOARD_ACCESS
// .lock()
// .unwrap()
// .board_hal
// .get_esp()
// .set_restart_to_conf(true);
// reboot_now_for_reboot.store(true, std::sync::atomic::Ordering::Relaxed);
// anyhow::Ok(())
// })
// .unwrap();
//
// unsafe { vTaskDelay(1) };
//
// server
}
async fn handle_json<'a, T, const N: usize>(

View File

@@ -30,9 +30,6 @@ where
Method::Post => {
let mut offset = 0_usize;
let mut chunk = 0;
// Erase only a single 4K block right before writing into it.
// The first block will be erased when offset == 0 below.
loop {
let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?;
if buf.len() == 0 {

View File

@@ -157,9 +157,9 @@ export interface Moistures {
export interface VersionInfo {
git_hash: string,
build_time: string,
partition: string,
ota_state: string
current: string,
slot0_state: string,
slot1_state: string,
}
export interface BatteryState {

View File

@@ -2,13 +2,16 @@
.otakey {
min-width: 100px;
}
.otavalue {
flex-grow: 1;
}
.otaform {
min-width: 100px;
flex-grow: 1;
}
.otachooser {
min-width: 100px;
width: 100%;
@@ -32,8 +35,12 @@
<span class="otavalue" id="firmware_partition"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Status:</span>
<span class="otavalue" id="firmware_state"></span>
<span class="otakey">State0:</span>
<span class="otavalue" id="firmware_state0"></span>
</div>
<div class="flexcontainer">
<span class="otakey">State1:</span>
<span class="otavalue" id="firmware_state1"></span>
</div>
<div class="flexcontainer">

View File

@@ -6,7 +6,8 @@ export class OTAView {
readonly firmware_buildtime: HTMLDivElement;
readonly firmware_githash: HTMLDivElement;
readonly firmware_partition: HTMLDivElement;
readonly firmware_state: HTMLDivElement;
readonly firmware_state0: HTMLDivElement;
readonly firmware_state1: HTMLDivElement;
constructor(controller: Controller) {
(document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html")
@@ -16,7 +17,9 @@ export class OTAView {
this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement;
this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement;
this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement;
this.firmware_state = document.getElementById("firmware_state") as HTMLDivElement;
this.firmware_state0 = document.getElementById("firmware_state0") as HTMLDivElement;
this.firmware_state1 = document.getElementById("firmware_state1") as HTMLDivElement;
const file = document.getElementById("firmware_file") as HTMLInputElement;
@@ -38,7 +41,8 @@ export class OTAView {
setVersion(versionInfo: VersionInfo) {
this.firmware_buildtime.innerText = versionInfo.build_time;
this.firmware_githash.innerText = versionInfo.git_hash;
this.firmware_partition.innerText = versionInfo.partition;
this.firmware_state.innerText = versionInfo.ota_state;
this.firmware_partition.innerText = versionInfo.current;
this.firmware_state0.innerText = versionInfo.slot0_state;
this.firmware_state1.innerText = versionInfo.slot1_state;
}
}