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:
@@ -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(), ×tamp)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,6 +19,17 @@ pub const SAVEGAME_SLOT_COUNT: usize = (8 * 1024 * 1024) / SAVEGAME_SLOT_SIZE -
|
|||||||
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user