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.
This commit is contained in:
2026-04-11 22:40:25 +02:00
parent 0d7074bd89
commit 95f7488fa3
8 changed files with 116 additions and 21 deletions

View File

@@ -570,7 +570,8 @@ impl Esp<'_> {
/// Persist a JSON config blob to the next wear-leveling slot. /// Persist a JSON config blob to the next wear-leveling slot.
pub(crate) async fn save_config(&mut self, mut config: Vec<u8>) -> FatResult<()> { pub(crate) async fn save_config(&mut self, mut config: Vec<u8>) -> FatResult<()> {
self.savegame.save(config.as_mut_slice())?; let timestamp = self.get_time().to_rfc3339();
self.savegame.save(config.as_mut_slice(), &timestamp)?;
Ok(()) Ok(())
} }

View File

@@ -1,8 +1,8 @@
use alloc::vec::Vec; use alloc::vec::Vec;
use embedded_savegame::storage::{Flash, Storage}; use embedded_savegame::storage::{Flash, Storage};
use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; use embedded_storage::nor_flash::{NorFlash, ReadNorFlash};
use esp_bootloader_esp_idf::partitions::{FlashRegion, Error as PartitionError}; use esp_bootloader_esp_idf::partitions::{Error as PartitionError, FlashRegion};
use serde::Serialize; use serde::{Deserialize, Serialize};
use crate::fat_error::{FatError, FatResult}; use crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage; 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 //keep a little of space at the end due to partition table offsets
const SAFETY: usize = 5; const SAFETY: usize = 5;
/// Number of slots in the 8 MB storage partition. /// 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`]. /// Metadata about a single existing save slot, returned by [`SavegameManager::list_saves`].
#[derive(Serialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
pub struct SaveInfo { pub struct SaveInfo {
pub idx: usize, pub idx: usize,
pub len: u32, pub len: u32,
/// UTC timestamp in RFC3339 format when the save was created
pub created_at: Option<alloc::string::String>,
}
/// 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<u8>,
} }
// ── Flash adapter ────────────────────────────────────────────────────────────── // ── 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 /// `scan()` advances the internal wear-leveling pointer to the latest valid
/// slot before `append()` writes to the next free one. /// slot before `append()` writes to the next free one.
/// Both operations are performed atomically on the same Storage instance. /// 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 mut st = self.storage();
let _slot = st.scan()?; let _slot = st.scan()?;
st.append(data)?; st.append(&mut serialized)?;
Ok(()) Ok(())
} }
/// Load the most recently saved data. Returns `None` if no valid save exists. /// 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<Option<Vec<u8>>> { pub fn load_latest(&mut self) -> FatResult<Option<Vec<u8>>> {
let mut st = self.storage(); let mut st = self.storage();
let slot = st.scan()?; let slot = st.scan()?;
@@ -116,7 +133,14 @@ impl SavegameManager {
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
match st.read(slot.idx, &mut buf)? { match st.read(slot.idx, &mut buf)? {
None => Ok(None), None => Ok(None),
Some(data) => Ok(Some(data.to_vec())), Some(data) => {
// Try to deserialize as SaveWrapper (new format)
match serde_json::from_slice::<SaveWrapper>(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 /// Load a specific save by slot index. Returns `None` if the slot is
/// empty or contains an invalid checksum. /// empty or contains an invalid checksum.
/// Unwraps the SaveWrapper and returns only the config data.
pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> { pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> {
let mut st = self.storage(); let mut st = self.storage();
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
match st.read(idx, &mut buf)? { match st.read(idx, &mut buf)? {
None => Ok(None), None => Ok(None),
Some(data) => Ok(Some(data.to_vec())), Some(data) => {
// Try to deserialize as SaveWrapper (new format)
match serde_json::from_slice::<SaveWrapper>(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 /// 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. /// 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<Vec<SaveInfo>> { pub fn list_saves(&mut self) -> FatResult<Vec<SaveInfo>> {
let mut saves = Vec::new(); let mut saves = Vec::new();
let mut st = self.storage(); let mut st = self.storage();
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
for idx in 0..SAVEGAME_SLOT_COUNT { for idx in 0..SAVEGAME_SLOT_COUNT {
if let Some(data) = st.read(idx, &mut buf)? { 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::<SaveWrapper>(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 { saves.push(SaveInfo {
idx, idx,
len: data.len() as u32, len,
created_at,
}); });
} }
} }

View File

@@ -1,5 +1,5 @@
use crate::BOARD_ACCESS;
use crate::vec; use crate::vec;
use crate::BOARD_ACCESS;
use alloc::string::ToString; use alloc::string::ToString;
use alloc::vec::Vec; use alloc::vec::Vec;
use bytemuck::{AnyBitPattern, Pod, Zeroable}; use bytemuck::{AnyBitPattern, Pod, Zeroable};
@@ -33,6 +33,12 @@ static mut LOG_ARRAY: LogArray = LogArray {
pub static LOG_ACCESS: Mutex<CriticalSectionRawMutex, &'static mut LogArray> = pub static LOG_ACCESS: Mutex<CriticalSectionRawMutex, &'static mut LogArray> =
unsafe { Mutex::new(&mut LOG_ARRAY) }; unsafe { Mutex::new(&mut LOG_ARRAY) };
mod interceptor;
pub use interceptor::InterceptorLogger;
pub static INTERCEPTOR: InterceptorLogger = InterceptorLogger::new();
pub struct LogRequest { pub struct LogRequest {
pub message_key: LogMessage, pub message_key: LogMessage,
pub number_a: u32, pub number_a: u32,

View File

@@ -167,7 +167,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let cur = match board.board_hal.get_rtc_module().get_rtc_time().await { let cur = match board.board_hal.get_rtc_module().get_rtc_time().await {
Ok(value) => { 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 value
} }
@@ -332,7 +336,14 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
match err { match err {
TankError::SensorDisabled => { /* unreachable */ } TankError::SensorDisabled => { /* unreachable */ }
TankError::SensorMissing(raw_value_mv) => { 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 } => { TankError::SensorValueError { value, min, max } => {
log( log(
@@ -455,7 +466,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
pump_ineffective, pump_ineffective,
result.median_current_ma, result.median_current_ma,
result.max_current_ma, result.max_current_ma,
result.min_current_ma result.min_current_ma,
) )
.await; .await;
} else if !state.pump_in_timeout(plant_config, &timezone_time) { } else if !state.pump_in_timeout(plant_config, &timezone_time) {
@@ -907,7 +918,7 @@ async fn pump_info(
pump_ineffective: bool, pump_ineffective: bool,
median_current_ma: u16, median_current_ma: u16,
max_current_ma: u16, max_current_ma: u16,
min_current_ma: u16 min_current_ma: u16,
) { ) {
let pump_info = PumpInfo { let pump_info = PumpInfo {
enabled: pump_active, enabled: pump_active,
@@ -920,7 +931,11 @@ async fn pump_info(
match serde_json::to_string(&pump_info) { match serde_json::to_string(&pump_info) {
Ok(state) => { 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) => { Err(err) => {
warn!("Error publishing pump state {err}"); warn!("Error publishing pump state {err}");
@@ -1167,7 +1182,7 @@ use embassy_time::WithTimeout;
#[esp_rtos::main] #[esp_rtos::main]
async fn main(spawner: Spawner) -> ! { async fn main(spawner: Spawner) -> ! {
// intialize embassy // intialize embassy
logger::init_logger_from_env(); crate::log::INTERCEPTOR.init();
spawner.must_spawn(crate::log::log_task()); spawner.must_spawn(crate::log::log_task());
//force init here! //force init here!
match BOARD_ACCESS.init( match BOARD_ACCESS.init(

View File

@@ -10,8 +10,8 @@ mod post_json;
use crate::fat_error::{FatError, FatResult}; use crate::fat_error::{FatError, FatResult};
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config}; use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::get_json::{ use crate::webserver::get_json::{
get_battery_state, get_config, get_live_moisture, get_log_localization_config, get_solar_state, delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
delete_save, get_time, get_timezones, get_version_web, list_saves, tank_info, 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_log::get_log;
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index}; use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
@@ -64,6 +64,7 @@ impl Handler for HTTPRequestRouter {
e e
})? })?
} else { } else {
crate::log::INTERCEPTOR.start_capture().await;
match method { match method {
Method::Get => match path { Method::Get => match path {
"/favicon.ico" => serve_favicon(conn).await?, "/favicon.ico" => serve_favicon(conn).await?,
@@ -138,7 +139,9 @@ impl Handler for HTTPRequestRouter {
.and_then(|s| s.parse().ok()); .and_then(|s| s.parse().ok());
match idx { match idx {
Some(idx) => Some(delete_save(conn, idx).await), 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 { } else {
None None
@@ -164,6 +167,7 @@ impl Handler for HTTPRequestRouter {
let response_time = Instant::now().duration_since(start).as_millis(); let response_time = Instant::now().duration_since(start).as_millis();
info!("\"{method} {path}\" {code} {response_time}ms"); info!("\"{method} {path}\" {code} {response_time}ms");
crate::log::INTERCEPTOR.stop_capture().await;
Ok(()) Ok(())
} }
} }
@@ -261,8 +265,17 @@ where
} }
}, },
Err(err) => { Err(err) => {
let error_text = err.to_string(); let mut error_text = err.to_string();
info!("error handling process {error_text}"); 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( conn.initiate_response(
500, 500,
Some("OK"), Some("OK"),

View File

@@ -37,6 +37,7 @@ export interface NetworkConfig {
export interface SaveInfo { export interface SaveInfo {
idx: number, idx: number,
len: number, len: number,
created_at: string | null,
} }
export interface FileList { export interface FileList {

View File

@@ -33,6 +33,7 @@ class SaveEntry {
this.view.innerHTML = template.replaceAll("${fileid}", String(fileid)); this.view.innerHTML = template.replaceAll("${fileid}", String(fileid));
let name = document.getElementById("file_" + fileid + "_name") as HTMLElement; 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 size = document.getElementById("file_" + fileid + "_size") as HTMLElement;
let deleteBtn = document.getElementById("file_" + fileid + "_delete") as HTMLButtonElement; let deleteBtn = document.getElementById("file_" + fileid + "_delete") as HTMLButtonElement;
deleteBtn.onclick = () => { deleteBtn.onclick = () => {
@@ -45,5 +46,17 @@ class SaveEntry {
name.innerText = "Slot " + saveinfo.idx; name.innerText = "Slot " + saveinfo.idx;
size.innerText = saveinfo.len + " bytes"; 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";
}
} }
} }

View File

@@ -2,6 +2,11 @@
<div id="file_${fileid}_name" class="filetitle">Slot</div> <div id="file_${fileid}_name" class="filetitle">Slot</div>
</div> </div>
<div class="flexcontainer">
<div class="filekey">Created</div>
<div id="file_${fileid}_created" class="filevalue"></div>
</div>
<div class="flexcontainer"> <div class="flexcontainer">
<div class="filekey">Size</div> <div class="filekey">Size</div>
<div id="file_${fileid}_size" class="filevalue"></div> <div id="file_${fileid}_size" class="filevalue"></div>