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>> { - let mut st = self.storage(); - let slot = st.scan()?; + let slot = (&mut self.storage).scan()?; match slot { None => Ok(None), Some(slot) => { let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; - match st.read(slot.idx, &mut buf)? { + match (&mut self.storage).read(slot.idx, &mut buf)? { None => Ok(None), Some(data) => { - // Try to deserialize as SaveWrapper (new format) - match serde_json::from_slice::(data) { - Ok(wrapper) => Ok(Some(wrapper.data)), - // Fallback to raw data for backwards compatibility - Err(_) => Ok(Some(data.to_vec())), + // Try to deserialize as SaveWrapper (new Bincode format) + match bincode::decode_from_slice::( + data, + bincode::config::standard(), + ) { + Ok((wrapper, _)) => Ok(Some(wrapper.data)), + Err(_) => { + // Fallback to JSON SaveWrapper (intermediate format) + match serde_json::from_slice::(data) { + Ok(wrapper) => Ok(Some(wrapper.data)), + // Fallback to raw data for backwards compatibility + Err(_) => Ok(Some(data.to_vec())), + } + } } } } @@ -150,16 +166,24 @@ impl SavegameManager { /// empty or contains an invalid checksum. /// Unwraps the SaveWrapper and returns only the config data. pub fn load_slot(&mut self, idx: usize) -> FatResult>> { - let mut st = self.storage(); let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; - match st.read(idx, &mut buf)? { + match (&mut self.storage).read(idx, &mut buf)? { None => Ok(None), Some(data) => { - // Try to deserialize as SaveWrapper (new format) - match serde_json::from_slice::(data) { - Ok(wrapper) => Ok(Some(wrapper.data)), - // Fallback to raw data for backwards compatibility - Err(_) => Ok(Some(data.to_vec())), + // Try to deserialize as SaveWrapper (new Bincode format) + match bincode::decode_from_slice::( + data, + bincode::config::standard(), + ) { + Ok((wrapper, _)) => Ok(Some(wrapper.data)), + Err(_) => { + // Fallback to JSON SaveWrapper (intermediate format) + match serde_json::from_slice::(data) { + Ok(wrapper) => Ok(Some(wrapper.data)), + // Fallback to raw data for backwards compatibility + Err(_) => Ok(Some(data.to_vec())), + } + } } } } @@ -167,8 +191,7 @@ impl SavegameManager { /// Erase a specific slot by index, effectively deleting it. pub fn delete_slot(&mut self, idx: usize) -> FatResult<()> { - let mut st = self.storage(); - st.erase(idx).map_err(Into::into) + (&mut self.storage).erase(idx).map_err(Into::into) } /// Iterate all slots and return metadata for every slot that contains a @@ -176,15 +199,23 @@ impl SavegameManager { /// Extracts timestamps from SaveWrapper if available. pub fn list_saves(&mut self) -> FatResult> { let mut saves = Vec::new(); - let mut st = self.storage(); let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; for idx in 0..SAVEGAME_SLOT_COUNT { - if let Some(data) = st.read(idx, &mut buf)? { - // Try to deserialize as SaveWrapper to extract timestamp - let (len, created_at) = match serde_json::from_slice::(data) { - Ok(wrapper) => (wrapper.data.len() as u32, Some(wrapper.created_at)), - // Old format without timestamp - Err(_) => (data.len() as u32, None), + if let Some(data) = (&mut self.storage).read(idx, &mut buf)? { + // Try to deserialize as SaveWrapper (new Bincode format) + let (len, created_at) = match bincode::decode_from_slice::( + data, + bincode::config::standard(), + ) { + Ok((wrapper, _)) => (wrapper.data.len() as u32, Some(wrapper.created_at)), + Err(_) => { + // Fallback to JSON SaveWrapper (intermediate format) + match serde_json::from_slice::(data) { + Ok(wrapper) => (wrapper.data.len() as u32, Some(wrapper.created_at)), + // Old format without timestamp + Err(_) => (data.len() as u32, None), + } + } }; saves.push(SaveInfo { diff --git a/Software/MainBoard/rust/src/main.rs b/Software/MainBoard/rust/src/main.rs index 6c1daf1..bf2acb4 100644 --- a/Software/MainBoard/rust/src/main.rs +++ b/Software/MainBoard/rust/src/main.rs @@ -749,6 +749,7 @@ pub async fn do_secure_pump( } None => Duration::from_millis(1), }; + hal::PlantHal::feed_watchdog(); Timer::after(sleep_time).await; pump_time_ms += 50; } @@ -1119,6 +1120,8 @@ async fn wait_infinity( Timer::after_millis(delay).await; + hal::PlantHal::feed_watchdog(); + if wait_type == WaitType::MqttConfig && !MQTT_STAY_ALIVE.load(Ordering::Relaxed) { reboot_now.store(true, Ordering::Relaxed); } diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index 9c5ac7f..7af0067 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -240,7 +240,10 @@ export class Controller { .then(_ => { controller.progressview.removeProgress("set_config"); setTimeout(() => { - controller.downloadConfig() + controller.downloadConfig().then(r => { + controller.updateSaveList().then(r => { + }); + }); }, 250) }) } diff --git a/Software/MainBoard/rust/src_webpack/src/submitView.ts b/Software/MainBoard/rust/src_webpack/src/submitView.ts index 6cf6b60..55ef5e4 100644 --- a/Software/MainBoard/rust/src_webpack/src/submitView.ts +++ b/Software/MainBoard/rust/src_webpack/src/submitView.ts @@ -28,11 +28,11 @@ export class SubmitView { controller.uploadConfig(this.json.textContent as string, (status: string) => { if (status != "OK") { // Show error toast (click to dismiss only) - const { toast } = require('./toast'); + const {toast} = require('./toast'); toast.error(status); } else { // Show info toast (auto hides after 5s, or click to dismiss sooner) - const { toast } = require('./toast'); + const {toast} = require('./toast'); toast.info('Config uploaded successfully'); } this.submit_status.innerHTML = status;