remove: eliminate file management and LittleFS-based filesystem, implement savegame management for JSON config slots with wear-leveling

This commit is contained in:
2026-04-08 22:12:55 +02:00
parent 1da6d54d7a
commit 301298522b
17 changed files with 318 additions and 622 deletions

View File

@@ -1,18 +1,16 @@
use crate::bail;
use crate::config::{NetworkConfig, PlantControllerConfig};
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::{PLANT_COUNT, TIME_ACCESS};
use crate::log::{LogMessage, LOG_ACCESS};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem;
use crate::hal::shared_flash::MutexFlashStorage;
use alloc::string::ToString;
use alloc::sync::Arc;
use alloc::{format, string::String, vec, vec::Vec};
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
use core::str::FromStr;
use core::sync::atomic::Ordering;
use embassy_executor::Spawner;
use embassy_net::udp::UdpSocket;
@@ -39,8 +37,6 @@ use esp_radio::wifi::{
AccessPointConfig, AccessPointInfo, AuthMethod, ClientConfig, ModeConfig, ScanConfig,
ScanTypeConfig, WifiController, WifiDevice, WifiStaState,
};
use littlefs2::fs::Filesystem;
use littlefs2_core::{FileType, PathBuf, SeekFrom};
use log::{error, info, warn};
use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
@@ -60,7 +56,6 @@ static mut LOW_VOLTAGE_DETECTED: i8 = 0;
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
static mut RESTART_TO_CONF: i8 = 0;
const CONFIG_FILE: &str = "config.json";
const NTP_SERVER: &str = "pool.ntp.org";
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
@@ -68,19 +63,6 @@ static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false);
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new();
#[derive(Serialize, Debug)]
pub struct FileInfo {
filename: String,
size: usize,
}
#[derive(Serialize, Debug)]
pub struct FileList {
total: usize,
used: usize,
files: Vec<FileInfo>,
}
#[derive(Copy, Clone, Default)]
struct Timestamp {
stamp: DateTime<Utc>,
@@ -117,7 +99,7 @@ impl NtpTimestampGenerator for Timestamp {
}
pub struct Esp<'a> {
pub fs: Arc<Mutex<CriticalSectionRawMutex, Filesystem<'static, LittleFs2Filesystem>>>,
pub savegame: SavegameManager,
pub rng: Rng,
//first starter (ap or sta will take these)
pub interface_sta: Option<WifiDevice<'static>>,
@@ -185,69 +167,6 @@ impl Esp<'_> {
}
}
}
pub(crate) async fn delete_file(&self, filename: String) -> FatResult<()> {
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
access.remove(&file)?;
Ok(())
}
pub(crate) async fn write_file(
&mut self,
filename: String,
offset: u32,
buf: &[u8],
) -> Result<(), FatError> {
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
access.open_file_with_options_and_then(
|options| options.read(true).write(true).create(true),
&file,
|file| {
file.seek(SeekFrom::Start(offset))?;
file.write(buf)?;
Ok(())
},
)?;
Ok(())
}
pub async fn get_size(&mut self, filename: String) -> FatResult<usize> {
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
let data = access.metadata(&file)?;
Ok(data.len())
}
pub(crate) async fn get_file(
&mut self,
filename: String,
chunk: u32,
) -> FatResult<([u8; 512], usize)> {
use littlefs2::io::Error as lfs2Error;
let file = PathBuf::try_from(filename.as_str())?;
let access = self.fs.lock().await;
let mut buf = [0_u8; 512];
let mut read = 0;
let offset = chunk * buf.len() as u32;
access.open_file_with_options_and_then(
|options| options.read(true),
&file,
|file| {
let length = file.len()? as u32;
if length == 0 {
Err(lfs2Error::IO)
} else if length > offset {
file.seek(SeekFrom::Start(offset))?;
read = file.read(&mut buf)?;
Ok(())
} else {
//exactly at end, do nothing
Ok(())
}
},
)?;
Ok((buf, read))
}
pub(crate) async fn write_ota(&mut self, offset: u32, buf: &[u8]) -> Result<(), FatError> {
let _ = check_erase(self.ota_target, offset, offset + 4096);
@@ -422,10 +341,7 @@ impl Esp<'_> {
.interface_ap
.take()
.context("AP interface already taken")?;
let gw_ip_addr_str = "192.168.71.1";
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).map_err(|_| FatError::String {
error: "failed to parse gateway ip".to_string(),
})?;
let gw_ip_addr = Ipv4Addr::new(192, 168, 71, 1);
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
address: Ipv4Cidr::new(gw_ip_addr, 24),
@@ -454,7 +370,7 @@ impl Esp<'_> {
println!("start net task");
spawner.spawn(net_task(runner)).ok();
println!("run dhcp");
spawner.spawn(run_dhcp(*stack, gw_ip_addr_str)).ok();
spawner.spawn(run_dhcp(*stack, gw_ip_addr)).ok();
loop {
if stack.is_link_up() {
@@ -465,7 +381,7 @@ impl Esp<'_> {
while !stack.is_config_up() {
Timer::after(Duration::from_millis(100)).await
}
println!("Connect to the AP `${ssid}` and point your browser to http://{gw_ip_addr_str}/");
println!("Connect to the AP `${ssid}` and point your browser to http://{gw_ip_addr}/");
stack
.config_v4()
.inspect(|c| println!("ipv4 config: {c:?}"));
@@ -624,48 +540,46 @@ impl Esp<'_> {
}
}
/// Load the most recently saved config from flash.
pub(crate) async fn load_config(&mut self) -> FatResult<PlantControllerConfig> {
let cfg = PathBuf::try_from(CONFIG_FILE)?;
let config_exist = self.fs.lock().await.exists(&cfg);
if !config_exist {
bail!("No config file stored")
match self.savegame.load_latest()? {
None => bail!("No config stored"),
Some(data) => {
let config: PlantControllerConfig = serde_json::from_slice(&data)?;
Ok(config)
}
}
let data = self.fs.lock().await.read::<4096>(&cfg)?;
let config: PlantControllerConfig = serde_json::from_slice(&data)?;
Ok(config)
}
pub(crate) async fn save_config(&mut self, config: Vec<u8>) -> FatResult<()> {
let filesystem = self.fs.lock().await;
let cfg = PathBuf::try_from(CONFIG_FILE)?;
filesystem.write(&cfg, &config)?;
/// Load a config from a specific save slot.
pub(crate) async fn load_config_slot(
&mut self,
idx: usize,
) -> FatResult<String> {
match self.savegame.load_slot(idx)? {
None => bail!("Slot {idx} is empty or invalid"),
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<u8>) -> FatResult<()> {
self.savegame.save(config.as_mut_slice())?;
Ok(())
}
pub(crate) async fn list_files(&self) -> FatResult<FileList> {
let path = PathBuf::new();
let fs = self.fs.lock().await;
let free_size = fs.available_space()?;
let total_size = fs.total_space();
/// Delete a specific save slot by erasing it on flash.
pub(crate) async fn delete_save_slot(&mut self, idx: usize) -> FatResult<()> {
self.savegame.delete_slot(idx)
}
let mut result = FileList {
total: total_size,
used: total_size - free_size,
files: Vec::new(),
};
fs.read_dir_and_then(&path, |dir| {
for entry in dir {
let e = entry?;
if e.file_type() == FileType::File {
result.files.push(FileInfo {
filename: e.path().to_string(),
size: e.metadata().len(),
});
}
}
Ok(())
})?;
Ok(result)
/// Return metadata about all valid save slots.
pub(crate) async fn list_saves(
&mut self,
) -> FatResult<alloc::vec::Vec<crate::hal::savegame_manager::SaveInfo>> {
self.savegame.list_saves()
}
pub(crate) async fn init_rtc_deepsleep_memory(
@@ -969,8 +883,8 @@ async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
}
#[embassy_executor::task]
async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
use core::net::{Ipv4Addr, SocketAddrV4};
async fn run_dhcp(stack: Stack<'static>, ip: Ipv4Addr) {
use core::net::SocketAddrV4;
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
@@ -979,14 +893,6 @@ async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
let ip = match Ipv4Addr::from_str(gw_ip_addr) {
Ok(ip) => ip,
Err(_) => {
error!("dhcp task failed to parse gw ip");
return;
}
};
let mut buf = [0u8; 1500];
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];

View File

@@ -1,88 +0,0 @@
use crate::hal::shared_flash::MutexFlashStorage;
use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash};
use esp_bootloader_esp_idf::partitions::FlashRegion;
use littlefs2::consts::U4096 as lfsCache;
use littlefs2::consts::U512 as lfsLookahead;
use littlefs2::driver::Storage as lfs2Storage;
use littlefs2::io::Error as lfs2Error;
use littlefs2::io::Result as lfs2Result;
use log::error;
pub struct LittleFs2Filesystem {
pub(crate) storage: &'static mut FlashRegion<'static, MutexFlashStorage>,
}
impl lfs2Storage for LittleFs2Filesystem {
const READ_SIZE: usize = 4096;
const WRITE_SIZE: usize = 4096;
const BLOCK_SIZE: usize = 4096; //usually optimal for flash access
const BLOCK_COUNT: usize = 8 * 1000 * 1000 / 4096; //8Mb in 4k blocks + a little space for stupid calculation errors
const BLOCK_CYCLES: isize = 100;
type CACHE_SIZE = lfsCache;
type LOOKAHEAD_SIZE = lfsLookahead;
fn read(&mut self, off: usize, buf: &mut [u8]) -> lfs2Result<usize> {
let read_size: usize = Self::READ_SIZE;
if off % read_size != 0 {
error!("Littlefs2Filesystem read error: offset not aligned to read size offset: {off} read_size: {read_size}");
return Err(lfs2Error::IO);
}
if buf.len() % read_size != 0 {
error!("Littlefs2Filesystem read error: length not aligned to read size length: {} read_size: {}", buf.len(), read_size);
return Err(lfs2Error::IO);
}
match self.storage.read(off as u32, buf) {
Ok(..) => Ok(buf.len()),
Err(err) => {
error!("Littlefs2Filesystem read error: {err:?}");
Err(lfs2Error::IO)
}
}
}
fn write(&mut self, off: usize, data: &[u8]) -> lfs2Result<usize> {
let write_size: usize = Self::WRITE_SIZE;
if off % write_size != 0 {
error!("Littlefs2Filesystem write error: offset not aligned to write size offset: {off} write_size: {write_size}");
return Err(lfs2Error::IO);
}
if data.len() % write_size != 0 {
error!("Littlefs2Filesystem write error: length not aligned to write size length: {} write_size: {}", data.len(), write_size);
return Err(lfs2Error::IO);
}
match self.storage.write(off as u32, data) {
Ok(..) => Ok(data.len()),
Err(err) => {
error!("Littlefs2Filesystem write error: {err:?}");
Err(lfs2Error::IO)
}
}
}
fn erase(&mut self, off: usize, len: usize) -> lfs2Result<usize> {
let block_size: usize = Self::BLOCK_SIZE;
if off % block_size != 0 {
error!("Littlefs2Filesystem erase error: offset not aligned to block size offset: {off} block_size: {block_size}");
return Err(lfs2Error::IO);
}
if len % block_size != 0 {
error!("Littlefs2Filesystem erase error: length not aligned to block size length: {len} block_size: {block_size}");
return Err(lfs2Error::IO);
}
match check_erase(self.storage, off as u32, (off + len) as u32) {
Ok(_) => {}
Err(err) => {
error!("Littlefs2Filesystem check erase error: {err:?}");
return Err(lfs2Error::IO);
}
}
match self.storage.erase(off as u32, (off + len) as u32) {
Ok(..) => Ok(len),
Err(err) => {
error!("Littlefs2Filesystem erase error: {err:?}");
Err(lfs2Error::IO)
}
}
}
}

View File

@@ -3,7 +3,7 @@ use lib_bms_protocol::BmsReadable;
pub(crate) mod battery;
// mod can_api; // replaced by external canapi crate
pub mod esp;
mod little_fs2storage_adapter;
pub(crate) mod savegame_manager;
pub(crate) mod rtc;
mod shared_flash;
mod v4_hal;
@@ -75,7 +75,7 @@ use measurements::{Current, Voltage};
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::battery::WCHI2CSlave;
use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem;
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::water::TankSensor;
use crate::log::LOG_ACCESS;
use embassy_sync::mutex::Mutex;
@@ -99,9 +99,7 @@ use esp_hal::uart::Uart;
use esp_hal::Blocking;
use esp_radio::{init, Controller};
use esp_storage::FlashStorage;
use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem};
use littlefs2::object_safe::DynStorage;
use log::{error, info, warn};
use log::{info, warn};
use portable_atomic::AtomicBool;
use serde::{Deserialize, Serialize};
use shared_flash::MutexFlashStorage;
@@ -379,33 +377,16 @@ impl PlantHal {
.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data(
DataPartitionSubType::LittleFs,
))?
.context("Data partition with littlefs not found")?;
.context("Storage data partition not found")?;
let data_partition = mk_static!(PartitionEntry, data_partition);
let data = mk_static!(
FlashRegion<MutexFlashStorage>,
FlashRegion<'static, MutexFlashStorage>,
data_partition.as_embedded_storage(flash_storage_3)
);
let lfs2filesystem = mk_static!(LittleFs2Filesystem, LittleFs2Filesystem { storage: data });
let alloc = mk_static!(Allocation<LittleFs2Filesystem>, lfs2Filesystem::allocate());
if lfs2filesystem.is_mountable() {
info!("Littlefs2 filesystem is mountable");
} else {
match lfs2filesystem.format() {
Ok(..) => {
info!("Littlefs2 filesystem is formatted");
}
Err(err) => {
error!("Littlefs2 filesystem could not be formatted: {err:?}");
}
}
}
#[allow(clippy::arc_with_non_send_sync)]
let fs = Arc::new(Mutex::new(
lfs2Filesystem::mount(alloc, lfs2filesystem)
.context("Could not mount lfs2 filesystem")?,
));
let savegame = SavegameManager::new(data);
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 {
@@ -415,7 +396,7 @@ impl PlantHal {
let ap = interfaces.ap;
let sta = interfaces.sta;
let mut esp = Esp {
fs,
savegame,
rng,
controller: Arc::new(Mutex::new(controller)),
interface_sta: Some(sta),

View File

@@ -0,0 +1,150 @@
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 crate::fat_error::{FatError, FatResult};
use crate::hal::shared_flash::MutexFlashStorage;
/// Size of each save slot in bytes (16 KB).
pub const SAVEGAME_SLOT_SIZE: usize = 16384;
/// Number of slots in the 8 MB storage partition.
pub const SAVEGAME_SLOT_COUNT: usize = 8 * 1024 * 1024 / SAVEGAME_SLOT_SIZE; // 512
/// 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,
}
// ── Flash adapter ──────────────────────────────────────────────────────────────
/// Newtype wrapper around a [`PartitionError`] so we can implement the
/// [`core::fmt::Debug`] bound required by [`embedded_savegame::storage::Flash`].
#[derive(Debug)]
pub struct SavegameFlashError(#[allow(dead_code)] PartitionError);
/// Adapts a `&mut FlashRegion<'static, MutexFlashStorage>` to the
/// [`embedded_savegame::storage::Flash`] trait.
///
/// `erase(addr)` erases exactly one slot (`SAVEGAME_SLOT_SIZE` bytes) starting
/// at `addr`, which is what embedded-savegame expects for NOR flash.
pub struct SavegameFlashAdapter<'a> {
region: &'a mut FlashRegion<'static, MutexFlashStorage>,
}
impl Flash for SavegameFlashAdapter<'_> {
type Error = SavegameFlashError;
fn read(&mut self, addr: u32, buf: &mut [u8]) -> Result<(), Self::Error> {
ReadNorFlash::read(self.region, addr, buf).map_err(SavegameFlashError)
}
fn write(&mut self, addr: u32, data: &mut [u8]) -> Result<(), Self::Error> {
NorFlash::write(self.region, addr, data).map_err(SavegameFlashError)
}
/// Erase one full slot at `addr`.
/// embedded-savegame calls this before writing to a slot, so we erase
/// the entire `SAVEGAME_SLOT_SIZE` bytes so subsequent writes land on
/// pre-erased (0xFF) pages.
fn erase(&mut self, addr: u32) -> Result<(), Self::Error> {
let end = addr + SAVEGAME_SLOT_SIZE as u32;
NorFlash::erase(self.region, addr, end).map_err(SavegameFlashError)
}
}
impl From<SavegameFlashError> for FatError {
fn from(e: SavegameFlashError) -> Self {
FatError::String {
error: alloc::format!("Savegame flash error: {:?}", e),
}
}
}
// ── SavegameManager ────────────────────────────────────────────────────────────
/// 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>,
}
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<SavegameFlashAdapter<'_>, SAVEGAME_SLOT_SIZE, SAVEGAME_SLOT_COUNT> {
Storage::new(SavegameFlashAdapter {
region: &mut *self.region,
})
}
/// Persist `data` (JSON bytes) to the next available slot.
///
/// `scan()` advances the internal wear-leveling pointer to the latest valid
/// slot before `append()` writes to the next free one.
pub fn save(&mut self, data: &mut [u8]) -> FatResult<()> {
let mut st = self.storage();
st.scan()?;
st.append(data)?;
Ok(())
}
/// Load the most recently saved data. Returns `None` if no valid save exists.
pub fn load_latest(&mut self) -> FatResult<Option<Vec<u8>>> {
let mut st = self.storage();
let slot = st.scan()?;
match slot {
None => Ok(None),
Some(slot) => {
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())),
}
}
}
}
/// Load a specific save by slot index. Returns `None` if the slot is
/// empty or contains an invalid checksum.
pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> {
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())),
}
}
/// 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)
}
/// 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.
pub fn list_saves(&mut self) -> FatResult<Vec<SaveInfo>> {
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)? {
saves.push(SaveInfo {
idx,
len: data.len() as u32,
});
}
}
Ok(saves)
}
}

View File

@@ -340,19 +340,12 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
bail!("pump current sensor not available");
}
Some(pump_ina) => {
let v = pump_ina
.shunt_voltage()
.map_err(|e| FatError::String {
error: alloc::format!("{e:?}"),
})
.map(|v| {
let shunt_voltage =
Voltage::from_microvolts(v.shunt_voltage_uv().abs() as f64);
let shut_value = Resistance::from_ohms(0.05_f64);
let current = shunt_voltage.as_volts() / shut_value.as_ohms();
Current::from_amperes(current)
})?;
Ok(v)
let raw = pump_ina.shunt_voltage()?;
let shunt_voltage =
Voltage::from_microvolts(raw.shunt_voltage_uv().abs() as f64);
let shut_value = Resistance::from_ohms(0.05_f64);
let current = shunt_voltage.as_volts() / shut_value.as_ohms();
Ok(Current::from_amperes(current))
}
}
}
@@ -609,10 +602,8 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
let info: Result<(BackupHeader, usize), bincode::error::DecodeError> =
bincode::decode_from_slice(&header_page_buffer[..], CONFIG);
info!("decoding header: {:?}", info);
info.map(|(header, _)| header)
.map_err(|e| FatError::String {
error: "Could not read backup header: ".to_string() + &e.to_string(),
})
let (header, _) = info.context("Could not read backup header")?;
Ok(header)
}
}