From 95f7488fa3b54550f317137e7ccfbb95e39ae1f5 Mon Sep 17 00:00:00 2001 From: Empire Phoenix Date: Sat, 11 Apr 2026 22:40:25 +0200 Subject: [PATCH] Add save timestamp support and log interceptor for enhanced debugging - Introduced `created_at` metadata for saves, enabling timestamp tracking. - Added `InterceptorLogger` to capture logs, aiding in error diagnostics. - Updated web UI to display save creation timestamps. - Improved save/load functionality to maintain compatibility with older formats. --- Software/MainBoard/rust/src/hal/esp.rs | 3 +- .../rust/src/hal/savegame_manager.rs | 59 ++++++++++++++++--- Software/MainBoard/rust/src/log/mod.rs | 8 ++- Software/MainBoard/rust/src/main.rs | 27 +++++++-- Software/MainBoard/rust/src/webserver/mod.rs | 21 +++++-- .../MainBoard/rust/src_webpack/src/api.ts | 1 + .../rust/src_webpack/src/fileview.ts | 13 ++++ .../rust/src_webpack/src/fileviewentry.html | 5 ++ 8 files changed, 116 insertions(+), 21 deletions(-) diff --git a/Software/MainBoard/rust/src/hal/esp.rs b/Software/MainBoard/rust/src/hal/esp.rs index 71531c1..82ead6d 100644 --- a/Software/MainBoard/rust/src/hal/esp.rs +++ b/Software/MainBoard/rust/src/hal/esp.rs @@ -570,7 +570,8 @@ impl Esp<'_> { /// Persist a JSON config blob to the next wear-leveling slot. pub(crate) async fn save_config(&mut self, mut config: Vec) -> FatResult<()> { - self.savegame.save(config.as_mut_slice())?; + let timestamp = self.get_time().to_rfc3339(); + self.savegame.save(config.as_mut_slice(), ×tamp)?; Ok(()) } diff --git a/Software/MainBoard/rust/src/hal/savegame_manager.rs b/Software/MainBoard/rust/src/hal/savegame_manager.rs index 195d355..ad44769 100644 --- a/Software/MainBoard/rust/src/hal/savegame_manager.rs +++ b/Software/MainBoard/rust/src/hal/savegame_manager.rs @@ -1,8 +1,8 @@ use alloc::vec::Vec; use embedded_savegame::storage::{Flash, Storage}; use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; -use esp_bootloader_esp_idf::partitions::{FlashRegion, Error as PartitionError}; -use serde::Serialize; +use esp_bootloader_esp_idf::partitions::{Error as PartitionError, FlashRegion}; +use serde::{Deserialize, Serialize}; use crate::fat_error::{FatError, FatResult}; use crate::hal::shared_flash::MutexFlashStorage; @@ -12,13 +12,24 @@ pub const SAVEGAME_SLOT_SIZE: usize = 16384; //keep a little of space at the end due to partition table offsets const SAFETY: usize = 5; /// Number of slots in the 8 MB storage partition. -pub const SAVEGAME_SLOT_COUNT: usize = (8 * 1024 * 1024) / SAVEGAME_SLOT_SIZE - SAFETY; // 507 +pub const SAVEGAME_SLOT_COUNT: usize = (8 * 1024 * 1024) / SAVEGAME_SLOT_SIZE - SAFETY; // 507 /// Metadata about a single existing save slot, returned by [`SavegameManager::list_saves`]. #[derive(Serialize, Debug, Clone)] pub struct SaveInfo { pub idx: usize, pub len: u32, + /// UTC timestamp in RFC3339 format when the save was created + pub created_at: Option, +} + +/// Wrapper that includes both the config data and metadata like creation timestamp. +#[derive(Serialize, Deserialize, Debug)] +struct SaveWrapper { + /// UTC timestamp in RFC3339 format + created_at: alloc::string::String, + /// Raw config JSON data + data: Vec, } // ── Flash adapter ────────────────────────────────────────────────────────────── @@ -94,19 +105,25 @@ impl SavegameManager { }) } - /// Persist `data` (JSON bytes) to the next available slot. + /// Persist `data` (JSON bytes) to the next available slot with a UTC timestamp. /// /// `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]) -> FatResult<()> { + pub fn save(&mut self, data: &mut [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(data)?; + st.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()?; @@ -116,7 +133,14 @@ impl SavegameManager { let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; match st.read(slot.idx, &mut buf)? { None => Ok(None), - Some(data) => Ok(Some(data.to_vec())), + 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())), + } + } } } } @@ -124,12 +148,20 @@ impl SavegameManager { /// Load a specific save by slot index. Returns `None` if the slot is /// 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)? { None => Ok(None), - Some(data) => Ok(Some(data.to_vec())), + 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())), + } + } } } @@ -141,15 +173,24 @@ impl SavegameManager { /// Iterate all slots and return metadata for every slot that contains a /// valid save, using the Storage read API to avoid assuming internal slot structure. + /// 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), + }; + saves.push(SaveInfo { idx, - len: data.len() as u32, + len, + created_at, }); } } diff --git a/Software/MainBoard/rust/src/log/mod.rs b/Software/MainBoard/rust/src/log/mod.rs index 3c2ce9c..ddc7328 100644 --- a/Software/MainBoard/rust/src/log/mod.rs +++ b/Software/MainBoard/rust/src/log/mod.rs @@ -1,5 +1,5 @@ -use crate::BOARD_ACCESS; use crate::vec; +use crate::BOARD_ACCESS; use alloc::string::ToString; use alloc::vec::Vec; use bytemuck::{AnyBitPattern, Pod, Zeroable}; @@ -33,6 +33,12 @@ static mut LOG_ARRAY: LogArray = LogArray { pub static LOG_ACCESS: Mutex = unsafe { Mutex::new(&mut LOG_ARRAY) }; +mod interceptor; + +pub use interceptor::InterceptorLogger; + +pub static INTERCEPTOR: InterceptorLogger = InterceptorLogger::new(); + pub struct LogRequest { pub message_key: LogMessage, pub number_a: u32, diff --git a/Software/MainBoard/rust/src/main.rs b/Software/MainBoard/rust/src/main.rs index caeb907..6c1daf1 100644 --- a/Software/MainBoard/rust/src/main.rs +++ b/Software/MainBoard/rust/src/main.rs @@ -167,7 +167,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { let cur = match board.board_hal.get_rtc_module().get_rtc_time().await { Ok(value) => { { - board.board_hal.get_esp().rtc.set_current_time_us(value.timestamp_micros() as u64); + board + .board_hal + .get_esp() + .rtc + .set_current_time_us(value.timestamp_micros() as u64); } value } @@ -332,7 +336,14 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { match err { TankError::SensorDisabled => { /* unreachable */ } TankError::SensorMissing(raw_value_mv) => { - log(LogMessage::TankSensorMissing, raw_value_mv as u32, 0, "", "").await + log( + LogMessage::TankSensorMissing, + raw_value_mv as u32, + 0, + "", + "", + ) + .await } TankError::SensorValueError { value, min, max } => { log( @@ -455,7 +466,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { pump_ineffective, result.median_current_ma, result.max_current_ma, - result.min_current_ma + result.min_current_ma, ) .await; } else if !state.pump_in_timeout(plant_config, &timezone_time) { @@ -907,7 +918,7 @@ async fn pump_info( pump_ineffective: bool, median_current_ma: u16, max_current_ma: u16, - min_current_ma: u16 + min_current_ma: u16, ) { let pump_info = PumpInfo { enabled: pump_active, @@ -920,7 +931,11 @@ async fn pump_info( match serde_json::to_string(&pump_info) { Ok(state) => { - board.board_hal.get_esp().mqtt_publish(&pump_topic, &state).await; + board + .board_hal + .get_esp() + .mqtt_publish(&pump_topic, &state) + .await; } Err(err) => { warn!("Error publishing pump state {err}"); @@ -1167,7 +1182,7 @@ use embassy_time::WithTimeout; #[esp_rtos::main] async fn main(spawner: Spawner) -> ! { // intialize embassy - logger::init_logger_from_env(); + crate::log::INTERCEPTOR.init(); spawner.must_spawn(crate::log::log_task()); //force init here! match BOARD_ACCESS.init( diff --git a/Software/MainBoard/rust/src/webserver/mod.rs b/Software/MainBoard/rust/src/webserver/mod.rs index f5b5f58..ea82a3c 100644 --- a/Software/MainBoard/rust/src/webserver/mod.rs +++ b/Software/MainBoard/rust/src/webserver/mod.rs @@ -10,8 +10,8 @@ mod post_json; use crate::fat_error::{FatError, FatResult}; use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config}; use crate::webserver::get_json::{ - get_battery_state, get_config, get_live_moisture, get_log_localization_config, get_solar_state, - delete_save, get_time, get_timezones, get_version_web, list_saves, tank_info, + delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config, + get_solar_state, get_time, get_timezones, get_version_web, list_saves, tank_info, }; use crate::webserver::get_log::get_log; use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index}; @@ -64,6 +64,7 @@ impl Handler for HTTPRequestRouter { e })? } else { + crate::log::INTERCEPTOR.start_capture().await; match method { Method::Get => match path { "/favicon.ico" => serve_favicon(conn).await?, @@ -138,7 +139,9 @@ impl Handler for HTTPRequestRouter { .and_then(|s| s.parse().ok()); match idx { Some(idx) => Some(delete_save(conn, idx).await), - None => Some(Err(FatError::String { error: "missing idx parameter".into() })), + None => Some(Err(FatError::String { + error: "missing idx parameter".into(), + })), } } else { None @@ -164,6 +167,7 @@ impl Handler for HTTPRequestRouter { let response_time = Instant::now().duration_since(start).as_millis(); info!("\"{method} {path}\" {code} {response_time}ms"); + crate::log::INTERCEPTOR.stop_capture().await; Ok(()) } } @@ -261,8 +265,17 @@ where } }, Err(err) => { - let error_text = err.to_string(); + let mut error_text = err.to_string(); info!("error handling process {error_text}"); + + if let Some(logs) = crate::log::INTERCEPTOR.stop_capture().await { + error_text.push_str("\n\nCaptured Logs:\n"); + for log in logs { + error_text.push_str(&log); + error_text.push('\n'); + } + } + conn.initiate_response( 500, Some("OK"), diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index 10497a7..e5aa625 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -37,6 +37,7 @@ export interface NetworkConfig { export interface SaveInfo { idx: number, len: number, + created_at: string | null, } export interface FileList { diff --git a/Software/MainBoard/rust/src_webpack/src/fileview.ts b/Software/MainBoard/rust/src_webpack/src/fileview.ts index 7952f64..5f44cb0 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileview.ts +++ b/Software/MainBoard/rust/src_webpack/src/fileview.ts @@ -33,6 +33,7 @@ class SaveEntry { this.view.innerHTML = template.replaceAll("${fileid}", String(fileid)); let name = document.getElementById("file_" + fileid + "_name") as HTMLElement; + let created = document.getElementById("file_" + fileid + "_created") as HTMLElement; let size = document.getElementById("file_" + fileid + "_size") as HTMLElement; let deleteBtn = document.getElementById("file_" + fileid + "_delete") as HTMLButtonElement; deleteBtn.onclick = () => { @@ -45,5 +46,17 @@ class SaveEntry { name.innerText = "Slot " + saveinfo.idx; size.innerText = saveinfo.len + " bytes"; + + // Format timestamp in browser's local timezone + if (saveinfo.created_at) { + try { + const date = new Date(saveinfo.created_at); + created.innerText = date.toLocaleString(); + } catch (e) { + created.innerText = "Invalid date"; + } + } else { + created.innerText = "Unknown"; + } } } diff --git a/Software/MainBoard/rust/src_webpack/src/fileviewentry.html b/Software/MainBoard/rust/src_webpack/src/fileviewentry.html index 9f0cc96..898141b 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileviewentry.html +++ b/Software/MainBoard/rust/src_webpack/src/fileviewentry.html @@ -2,6 +2,11 @@
Slot
+
+
Created
+
+
+
Size