diff --git a/Software/MainBoard/rust/.idea/vcs.xml b/Software/MainBoard/rust/.idea/vcs.xml
index c2365ab..298f634 100644
--- a/Software/MainBoard/rust/.idea/vcs.xml
+++ b/Software/MainBoard/rust/.idea/vcs.xml
@@ -2,5 +2,6 @@
+
\ No newline at end of file
diff --git a/Software/MainBoard/rust/src/hal/esp.rs b/Software/MainBoard/rust/src/hal/esp.rs
index 82ead6d..0b9663c 100644
--- a/Software/MainBoard/rust/src/hal/esp.rs
+++ b/Software/MainBoard/rust/src/hal/esp.rs
@@ -140,7 +140,8 @@ macro_rules! mk_static {
impl Esp<'_> {
pub fn get_time(&self) -> DateTime {
- DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64).unwrap_or(DateTime::UNIX_EPOCH)
+ DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64)
+ .unwrap_or(DateTime::UNIX_EPOCH)
}
pub fn set_time(&mut self, time: DateTime) {
@@ -517,10 +518,7 @@ impl Esp<'_> {
Ok(*stack)
}
- pub fn deep_sleep(
- &mut self,
- duration_in_ms: u64,
- ) -> ! {
+ pub fn deep_sleep(&mut self, duration_in_ms: u64) -> ! {
// Mark the current OTA image as valid if we reached here while in pending verify.
if let Ok(cur) = self.ota.current_ota_state() {
if cur == OtaImageState::PendingVerify {
@@ -556,23 +554,24 @@ impl Esp<'_> {
}
/// Load a config from a specific save slot.
- pub(crate) async fn load_config_slot(
- &mut self,
- idx: usize,
- ) -> FatResult {
+ pub(crate) async fn load_config_slot(&mut self, idx: usize) -> FatResult {
match self.savegame.load_slot(idx)? {
None => bail!("Slot {idx} is empty or invalid"),
- Some(data) => {
- Ok(String::from_utf8_lossy(&data).to_string())
- }
+ Some(data) => Ok(String::from_utf8_lossy(&data).to_string()),
}
}
/// Persist a JSON config blob to the next wear-leveling slot.
- pub(crate) async fn save_config(&mut self, mut config: Vec) -> FatResult<()> {
+ /// Retries once on flash error.
+ pub(crate) async fn save_config(&mut self, config: Vec) -> FatResult<()> {
let timestamp = self.get_time().to_rfc3339();
- self.savegame.save(config.as_mut_slice(), ×tamp)?;
- Ok(())
+ match self.savegame.save(config.as_slice(), ×tamp) {
+ Ok(()) => Ok(()),
+ Err(e) => {
+ warn!("First save attempt failed: {e:?}. Retrying...");
+ self.savegame.save(config.as_slice(), ×tamp)
+ }
+ }
}
/// Delete a specific save slot by erasing it on flash.
@@ -608,7 +607,14 @@ impl Esp<'_> {
if to_config_mode {
RESTART_TO_CONF = 1;
}
- log(LogMessage::RestartToConfig, RESTART_TO_CONF as u32, 0, "", "").await;
+ log(
+ LogMessage::RestartToConfig,
+ RESTART_TO_CONF as u32,
+ 0,
+ "",
+ "",
+ )
+ .await;
log(
LogMessage::LowVoltage,
LOW_VOLTAGE_DETECTED as u32,
@@ -702,6 +708,7 @@ impl Esp<'_> {
let mqtt_timeout = 15000;
let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
+ crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
@@ -720,6 +727,7 @@ impl Esp<'_> {
let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
+ crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
diff --git a/Software/MainBoard/rust/src/hal/mod.rs b/Software/MainBoard/rust/src/hal/mod.rs
index 8d99879..fec5d07 100644
--- a/Software/MainBoard/rust/src/hal/mod.rs
+++ b/Software/MainBoard/rust/src/hal/mod.rs
@@ -3,8 +3,8 @@ use lib_bms_protocol::BmsReadable;
pub(crate) mod battery;
// mod can_api; // replaced by external canapi crate
pub mod esp;
-pub(crate) mod savegame_manager;
pub(crate) mod rtc;
+pub(crate) mod savegame_manager;
mod shared_flash;
mod v4_hal;
mod water;
@@ -85,7 +85,7 @@ use esp_alloc as _;
use esp_backtrace as _;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_hal::delay::Delay;
-use esp_hal::i2c::master::{BusTimeout, Config, I2c};
+use esp_hal::i2c::master::{BusTimeout, Config, FsmTimeout, I2c, SoftwareTimeout};
use esp_hal::interrupt::software::SoftwareInterruptControl;
use esp_hal::pcnt::unit::Unit;
use esp_hal::pcnt::Pcnt;
@@ -93,7 +93,7 @@ use esp_hal::rng::Rng;
use esp_hal::rtc_cntl::{Rtc, SocResetReason};
use esp_hal::system::reset_reason;
use esp_hal::time::Rate;
-use esp_hal::timer::timg::TimerGroup;
+use esp_hal::timer::timg::{MwdtStage, TimerGroup, Wdt};
use esp_hal::uart::Uart;
use esp_hal::Blocking;
use esp_radio::{init, Controller};
@@ -108,6 +108,13 @@ pub const PLANT_COUNT: usize = 8;
pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false);
+pub static WATCHDOG: OnceLock<
+ embassy_sync::blocking_mutex::Mutex<
+ CriticalSectionRawMutex,
+ RefCell>,
+ >,
+> = OnceLock::new();
+
const TANK_MULTI_SAMPLE: usize = 11;
pub static I2C_DRIVER: OnceLock<
embassy_sync::blocking_mutex::Mutex>>,
@@ -174,6 +181,9 @@ pub trait BoardInteraction<'a> {
// Indicate progress is active to suppress default wait_infinity blinking
PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed);
+ // Feed watchdog during long-running webserver operations
+ PlantHal::feed_watchdog();
+
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 {
@@ -251,6 +261,16 @@ impl PlantHal {
let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_int.software_interrupt0);
+ // Initialize and enable the watchdog with 30 second timeout
+ let mut wdt = timg0.wdt;
+ wdt.set_timeout(MwdtStage::Stage0, esp_hal::time::Duration::from_secs(30));
+ wdt.enable();
+ WATCHDOG
+ .init(embassy_sync::blocking_mutex::Mutex::new(RefCell::new(wdt)))
+ .map_err(|_| FatError::String {
+ error: "Watchdog already initialized".to_string(),
+ })?;
+
let boot_button = Input::new(
peripherals.GPIO9,
InputConfig::default().with_pull(Pull::None),
@@ -380,7 +400,11 @@ impl PlantHal {
);
let savegame = SavegameManager::new(data);
- info!("Savegame storage initialized ({} slots × {} KB)", savegame_manager::SAVEGAME_SLOT_COUNT, savegame_manager::SAVEGAME_SLOT_SIZE / 1024);
+ info!(
+ "Savegame storage initialized ({} slots × {} KB)",
+ savegame_manager::SAVEGAME_SLOT_COUNT,
+ savegame_manager::SAVEGAME_SLOT_SIZE / 1024
+ );
let uart0 =
Uart::new(peripherals.UART0, UartConfig::default()).map_err(|_| FatError::String {
@@ -458,11 +482,16 @@ impl PlantHal {
let sda = peripherals.GPIO20;
let scl = peripherals.GPIO19;
+ // Configure I2C with 1-second timeout
+ // At 100 Hz I2C clock, one bus cycle = 10ms
+ // For 1 second timeout: 100 bus cycles
let i2c = I2c::new(
peripherals.I2C0,
Config::default()
- .with_frequency(Rate::from_hz(100))
- .with_timeout(BusTimeout::Maximum),
+ //.with_frequency(Rate::from_hz(100))
+ //1s at 100khz
+ .with_timeout(BusTimeout::BusCycles(100_000))
+ .with_scl_main_st_timeout(FsmTimeout::new(21)?),
)?
.with_scl(scl)
.with_sda(sda);
@@ -563,6 +592,15 @@ impl PlantHal {
Ok(Mutex::new(hal))
}
+
+ /// Feed the watchdog timer to prevent system reset
+ pub fn feed_watchdog() {
+ if let Some(wdt_mutex) = WATCHDOG.try_get() {
+ wdt_mutex.lock(|cell| {
+ cell.borrow_mut().feed();
+ });
+ }
+ }
}
fn ota_state(
diff --git a/Software/MainBoard/rust/src/hal/savegame_manager.rs b/Software/MainBoard/rust/src/hal/savegame_manager.rs
index ad44769..bd1d76a 100644
--- a/Software/MainBoard/rust/src/hal/savegame_manager.rs
+++ b/Software/MainBoard/rust/src/hal/savegame_manager.rs
@@ -1,7 +1,9 @@
use alloc::vec::Vec;
+use bincode::{Decode, Encode};
use embedded_savegame::storage::{Flash, Storage};
use embedded_storage::nor_flash::{NorFlash, ReadNorFlash};
-use esp_bootloader_esp_idf::partitions::{Error as PartitionError, FlashRegion};
+use esp_bootloader_esp_idf::partitions::{Error as PartitionError, Error, FlashRegion};
+use log::{error, info};
use serde::{Deserialize, Serialize};
use crate::fat_error::{FatError, FatResult};
@@ -24,7 +26,7 @@ pub struct SaveInfo {
}
/// Wrapper that includes both the config data and metadata like creation timestamp.
-#[derive(Serialize, Deserialize, Debug)]
+#[derive(Serialize, Deserialize, Debug, Encode, Decode)]
struct SaveWrapper {
/// UTC timestamp in RFC3339 format
created_at: alloc::string::String,
@@ -71,7 +73,21 @@ impl Flash for SavegameFlashAdapter<'_> {
// Align end address up to erase boundary
let end = addr + SAVEGAME_SLOT_SIZE as u32;
let aligned_end = ((end + ERASE_SIZE - 1) / ERASE_SIZE) * ERASE_SIZE;
- NorFlash::erase(self.region, aligned_start, aligned_end).map_err(SavegameFlashError)
+
+ if aligned_start != addr || aligned_end != end {
+ log::warn!("Flash erase address not aligned: addr=0x{:x}, slot_size=0x{:x}. Aligned to 0x{:x}-0x{:x}", addr, SAVEGAME_SLOT_SIZE, aligned_start, aligned_end);
+ }
+
+ match NorFlash::erase(self.region, aligned_start, aligned_end) {
+ Ok(_) => Ok(()),
+ Err(err) => {
+ error!(
+ "Flash erase failed: {:?}. 0x{:x}-0x{:x}",
+ err, aligned_start, aligned_end
+ );
+ Err(SavegameFlashError(err))
+ }
+ }
}
}
@@ -88,21 +104,14 @@ impl From for FatError {
/// High-level save-game manager that stores JSON config blobs on the storage
/// partition using [`embedded_savegame`] for wear leveling and power-fail safety.
pub struct SavegameManager {
- region: &'static mut FlashRegion<'static, MutexFlashStorage>,
+ storage: Storage, SAVEGAME_SLOT_SIZE, SAVEGAME_SLOT_COUNT>,
}
impl SavegameManager {
pub fn new(region: &'static mut FlashRegion<'static, MutexFlashStorage>) -> Self {
- Self { region }
- }
-
- /// Build a short-lived [`Storage`] that borrows our flash region.
- fn storage(
- &mut self,
- ) -> Storage, SAVEGAME_SLOT_SIZE, SAVEGAME_SLOT_COUNT> {
- Storage::new(SavegameFlashAdapter {
- region: &mut *self.region,
- })
+ Self {
+ storage: Storage::new(SavegameFlashAdapter { region }),
+ }
}
/// Persist `data` (JSON bytes) to the next available slot with a UTC timestamp.
@@ -110,35 +119,42 @@ impl SavegameManager {
/// `scan()` advances the internal wear-leveling pointer to the latest valid
/// slot before `append()` writes to the next free one.
/// Both operations are performed atomically on the same Storage instance.
- pub fn save(&mut self, data: &mut [u8], timestamp: &str) -> FatResult<()> {
+ pub fn save(&mut self, data: &[u8], timestamp: &str) -> FatResult<()> {
let wrapper = SaveWrapper {
created_at: alloc::string::String::from(timestamp),
data: data.to_vec(),
};
- let mut serialized = serde_json::to_vec(&wrapper)?;
- let mut st = self.storage();
- let _slot = st.scan()?;
- st.append(&mut serialized)?;
+ let mut serialized = bincode::encode_to_vec(&wrapper, bincode::config::standard())?;
+ info!("Serialized config with size {}", serialized.len());
+ (&mut self.storage).append(&mut serialized)?;
Ok(())
}
/// Load the most recently saved data. Returns `None` if no valid save exists.
/// Unwraps the SaveWrapper and returns only the config data.
pub fn load_latest(&mut self) -> FatResult