From 301298522bf91a4761144266fabad2d18ef86fff Mon Sep 17 00:00:00 2001 From: Empire Date: Wed, 8 Apr 2026 22:12:55 +0200 Subject: [PATCH] remove: eliminate file management and LittleFS-based filesystem, implement savegame management for JSON config slots with wear-leveling --- Software/MainBoard/rust/.cargo/config.toml | 2 - Software/MainBoard/rust/Cargo.toml | 3 +- Software/MainBoard/rust/src/fat_error.rs | 25 --- Software/MainBoard/rust/src/hal/esp.rs | 174 ++++-------------- .../rust/src/hal/little_fs2storage_adapter.rs | 88 --------- Software/MainBoard/rust/src/hal/mod.rs | 35 +--- .../rust/src/hal/savegame_manager.rs | 150 +++++++++++++++ Software/MainBoard/rust/src/hal/v4_hal.rs | 25 +-- .../rust/src/webserver/file_manager.rs | 160 ---------------- .../MainBoard/rust/src/webserver/get_json.rs | 31 +++- Software/MainBoard/rust/src/webserver/mod.rs | 41 ++++- .../MainBoard/rust/src_webpack/src/api.ts | 5 + .../rust/src_webpack/src/fileview.html | 38 +--- .../rust/src_webpack/src/fileview.ts | 89 +++------ .../rust/src_webpack/src/fileviewentry.html | 9 +- .../MainBoard/rust/src_webpack/src/main.ts | 63 ++----- website/themes/blowfish | 2 +- 17 files changed, 318 insertions(+), 622 deletions(-) delete mode 100644 Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs create mode 100644 Software/MainBoard/rust/src/hal/savegame_manager.rs delete mode 100644 Software/MainBoard/rust/src/webserver/file_manager.rs diff --git a/Software/MainBoard/rust/.cargo/config.toml b/Software/MainBoard/rust/.cargo/config.toml index 55152e5..efcb842 100644 --- a/Software/MainBoard/rust/.cargo/config.toml +++ b/Software/MainBoard/rust/.cargo/config.toml @@ -23,8 +23,6 @@ target = "riscv32imac-unknown-none-elf" CHRONO_TZ_TIMEZONE_FILTER = "UTC|America/New_York|America/Chicago|America/Los_Angeles|Europe/London|Europe/Berlin|Europe/Paris|Asia/Tokyo|Asia/Shanghai|Asia/Kolkata|Australia/Sydney|America/Sao_Paulo|Africa/Johannesburg|Asia/Dubai|Pacific/Auckland" CARGO_WORKSPACE_DIR = { value = "", relative = true } ESP_LOG = "info" -PATH = { value = "../../../bin:/usr/bin:/usr/local/bin", force = true, relative = true } - [unstable] diff --git a/Software/MainBoard/rust/Cargo.toml b/Software/MainBoard/rust/Cargo.toml index 18dde2f..ce9fdaf 100644 --- a/Software/MainBoard/rust/Cargo.toml +++ b/Software/MainBoard/rust/Cargo.toml @@ -109,8 +109,7 @@ pca9535 = { version = "2.0.0" } ina219 = { version = "0.2.0" } # Storage and filesystem -littlefs2 = { version = "0.6.1", features = ["c-stubs", "alloc"] } -littlefs2-core = "0.1.2" +embedded-savegame = { version = "0.3.0" } # Serialization / codecs serde = { version = "1.0.228", features = ["derive", "alloc"], default-features = false } diff --git a/Software/MainBoard/rust/src/fat_error.rs b/Software/MainBoard/rust/src/fat_error.rs index 6fbccc0..9588f0e 100644 --- a/Software/MainBoard/rust/src/fat_error.rs +++ b/Software/MainBoard/rust/src/fat_error.rs @@ -16,28 +16,18 @@ use esp_hal::twai::EspTwaiError; use esp_radio::wifi::WifiError; use ina219::errors::{BusVoltageReadError, ShuntVoltageReadError}; use lib_bms_protocol::BmsProtocolError; -use littlefs2_core::PathError; use onewire::Error; use pca9535::ExpanderError; //All error superconstruct #[derive(Debug)] pub enum FatError { - BMSError { - error: String, - }, OneWireError { error: Error, }, String { error: String, }, - LittleFSError { - error: littlefs2_core::Error, - }, - PathError { - error: PathError, - }, TryLockError { error: TryLockError, }, @@ -88,8 +78,6 @@ impl fmt::Display for FatError { } FatError::OneWireError { error } => write!(f, "OneWireError {error:?}"), FatError::String { error } => write!(f, "{error}"), - FatError::LittleFSError { error } => write!(f, "LittleFSError {error:?}"), - FatError::PathError { error } => write!(f, "PathError {error:?}"), FatError::TryLockError { error } => write!(f, "TryLockError {error:?}"), FatError::WifiError { error } => write!(f, "WifiError {error:?}"), FatError::SerdeError { error } => write!(f, "SerdeError {error:?}"), @@ -108,7 +96,6 @@ impl fmt::Display for FatError { write!(f, "CanBusError {error:?}") } FatError::SNTPError { error } => write!(f, "SNTPError {error:?}"), - FatError::BMSError { error } => write!(f, "BMSError, {error}"), FatError::OTAError => { write!(f, "OTA missing partition") } @@ -173,18 +160,6 @@ impl From> for FatError { FatError::OneWireError { error } } } -impl From for FatError { - fn from(value: littlefs2_core::Error) -> Self { - FatError::LittleFSError { error: value } - } -} - -impl From for FatError { - fn from(value: PathError) -> Self { - FatError::PathError { error: value } - } -} - impl From for FatError { fn from(value: TryLockError) -> Self { FatError::TryLockError { error: value } diff --git a/Software/MainBoard/rust/src/hal/esp.rs b/Software/MainBoard/rust/src/hal/esp.rs index a139635..aa608a5 100644 --- a/Software/MainBoard/rust/src/hal/esp.rs +++ b/Software/MainBoard/rust/src/hal/esp.rs @@ -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 = OnceLock::new(); -#[derive(Serialize, Debug)] -pub struct FileInfo { - filename: String, - size: usize, -} - -#[derive(Serialize, Debug)] -pub struct FileList { - total: usize, - used: usize, - files: Vec, -} - #[derive(Copy, Clone, Default)] struct Timestamp { stamp: DateTime, @@ -117,7 +99,7 @@ impl NtpTimestampGenerator for Timestamp { } pub struct Esp<'a> { - pub fs: Arc>>, + pub savegame: SavegameManager, pub rng: Rng, //first starter (ap or sta will take these) pub interface_sta: Option>, @@ -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 { - 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 { - 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) -> 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 { + 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) -> FatResult<()> { + self.savegame.save(config.as_mut_slice())?; Ok(()) } - pub(crate) async fn list_files(&self) -> FatResult { - 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> { + 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]; diff --git a/Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs b/Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs deleted file mode 100644 index c3b7a80..0000000 --- a/Software/MainBoard/rust/src/hal/little_fs2storage_adapter.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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) - } - } - } -} diff --git a/Software/MainBoard/rust/src/hal/mod.rs b/Software/MainBoard/rust/src/hal/mod.rs index 2aba0d7..0fe230d 100644 --- a/Software/MainBoard/rust/src/hal/mod.rs +++ b/Software/MainBoard/rust/src/hal/mod.rs @@ -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, + FlashRegion<'static, MutexFlashStorage>, data_partition.as_embedded_storage(flash_storage_3) ); - let lfs2filesystem = mk_static!(LittleFs2Filesystem, LittleFs2Filesystem { storage: data }); - let alloc = mk_static!(Allocation, 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), diff --git a/Software/MainBoard/rust/src/hal/savegame_manager.rs b/Software/MainBoard/rust/src/hal/savegame_manager.rs new file mode 100644 index 0000000..7f9479b --- /dev/null +++ b/Software/MainBoard/rust/src/hal/savegame_manager.rs @@ -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 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, 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>> { + 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>> { + 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> { + 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) + } +} diff --git a/Software/MainBoard/rust/src/hal/v4_hal.rs b/Software/MainBoard/rust/src/hal/v4_hal.rs index 06e1865..e91997b 100644 --- a/Software/MainBoard/rust/src/hal/v4_hal.rs +++ b/Software/MainBoard/rust/src/hal/v4_hal.rs @@ -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) } } diff --git a/Software/MainBoard/rust/src/webserver/file_manager.rs b/Software/MainBoard/rust/src/webserver/file_manager.rs deleted file mode 100644 index 03304ae..0000000 --- a/Software/MainBoard/rust/src/webserver/file_manager.rs +++ /dev/null @@ -1,160 +0,0 @@ -use crate::fat_error::{FatError, FatResult}; -use crate::webserver::read_up_to_bytes_from_request; -use crate::BOARD_ACCESS; -use alloc::borrow::ToOwned; -use alloc::format; -use alloc::string::String; -use edge_http::io::server::Connection; -use edge_http::Method; -use edge_nal::io::{Read, Write}; -use log::info; - -pub(crate) async fn list_files( - _request: &mut Connection<'_, T, N>, -) -> FatResult> { - let mut board = BOARD_ACCESS.get().await.lock().await; - let result = board.board_hal.get_esp().list_files().await?; - let file_list_json = serde_json::to_string(&result)?; - Ok(Some(file_list_json)) -} -pub(crate) async fn file_operations( - conn: &mut Connection<'_, T, { N }>, - method: Method, - path: &&str, - prefix: &&str, -) -> Result, FatError> -where - T: Read + Write, -{ - let filename = &path[prefix.len()..]; - info!("file request for {filename} with method {method}"); - Ok(match method { - Method::Delete => { - let mut board = BOARD_ACCESS.get().await.lock().await; - board - .board_hal - .get_esp() - .delete_file(filename.to_owned()) - .await?; - conn.initiate_response( - 200, - Some("OK"), - &[ - ("Access-Control-Allow-Origin", "*"), - ("Access-Control-Allow-Headers", "*"), - ("Access-Control-Allow-Methods", "*"), - ], - ) - .await?; - Some(200) - } - Method::Get => { - let disposition = format!("attachment; filename=\"{filename}\""); - let size = { - let mut board = BOARD_ACCESS.get().await.lock().await; - board - .board_hal - .get_esp() - .get_size(filename.to_owned()) - .await? - }; - - conn.initiate_response( - 200, - Some("OK"), - &[ - ("Content-Type", "application/octet-stream"), - ("Content-Disposition", disposition.as_str()), - ("Content-Length", &format!("{size}")), - ("Access-Control-Allow-Origin", "*"), - ("Access-Control-Allow-Headers", "*"), - ("Access-Control-Allow-Methods", "*"), - ], - ) - .await?; - - let mut chunk = 0; - loop { - let mut board = BOARD_ACCESS.get().await.lock().await; - board.board_hal.progress(chunk).await; - let read_chunk = board - .board_hal - .get_esp() - .get_file(filename.to_owned(), chunk) - .await?; - let length = read_chunk.1; - if length == 0 { - info!("file request for {filename} finished"); - break; - } - let data = &read_chunk.0[0..length]; - conn.write_all(data).await?; - if length < read_chunk.0.len() { - info!("file request for {filename} finished"); - break; - } - chunk += 1; - } - BOARD_ACCESS - .get() - .await - .lock() - .await - .board_hal - .clear_progress() - .await; - Some(200) - } - Method::Post => { - { - let mut board = BOARD_ACCESS.get().await.lock().await; - //ensure the file is deleted first; otherwise we would need to truncate the file which will not work with streaming - let _ = board - .board_hal - .get_esp() - .delete_file(filename.to_owned()) - .await; - } - - let mut offset = 0_usize; - let mut chunk = 0; - loop { - let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?; - if buf.is_empty() { - info!("file request for {filename} finished"); - break; - } else { - let mut board = BOARD_ACCESS.get().await.lock().await; - board.board_hal.progress(chunk as u32).await; - board - .board_hal - .get_esp() - .write_file(filename.to_owned(), offset as u32, &buf) - .await?; - } - offset += buf.len(); - chunk += 1; - } - BOARD_ACCESS - .get() - .await - .lock() - .await - .board_hal - .clear_progress() - .await; - conn.initiate_response( - 200, - Some("OK"), - &[ - ("Access-Control-Allow-Origin", "*"), - ("Access-Control-Allow-Headers", "*"), - ("Access-Control-Allow-Methods", "*"), - ], - ) - .await?; - Some(200) - } - _ => None, - }) -} diff --git a/Software/MainBoard/rust/src/webserver/get_json.rs b/Software/MainBoard/rust/src/webserver/get_json.rs index 107ee90..e97b75a 100644 --- a/Software/MainBoard/rust/src/webserver/get_json.rs +++ b/Software/MainBoard/rust/src/webserver/get_json.rs @@ -114,12 +114,39 @@ pub(crate) async fn get_version_web( Ok(Some(serde_json::to_string(&get_version(&mut board).await)?)) } +/// Return the current in-memory config, or — when `saveidx` is `Some(idx)` — +/// the JSON stored in save slot `idx`. pub(crate) async fn get_config( _request: &mut Connection<'_, T, N>, + saveidx: Option, +) -> FatResult> { + let mut board = BOARD_ACCESS.get().await.lock().await; + let json = match saveidx { + None => serde_json::to_string(board.board_hal.get_config())?, + Some(idx) => { + board.board_hal.get_esp().load_config_slot(idx).await? + } + }; + Ok(Some(json)) +} + +/// Return a JSON array describing every valid save slot on flash. +pub(crate) async fn list_saves( + _request: &mut Connection<'_, T, N>, ) -> FatResult> { let mut board = BOARD_ACCESS.get().await.lock().await; - let json = serde_json::to_string(&board.board_hal.get_config())?; - Ok(Some(json)) + let saves = board.board_hal.get_esp().list_saves().await?; + Ok(Some(serde_json::to_string(&saves)?)) +} + +/// Erase (delete) a single save slot by index. +pub(crate) async fn delete_save( + _request: &mut Connection<'_, T, N>, + idx: usize, +) -> FatResult> { + let mut board = BOARD_ACCESS.get().await.lock().await; + board.board_hal.get_esp().delete_save_slot(idx).await?; + Ok(None) } pub(crate) async fn get_battery_state( diff --git a/Software/MainBoard/rust/src/webserver/mod.rs b/Software/MainBoard/rust/src/webserver/mod.rs index d6e716c..f5b5f58 100644 --- a/Software/MainBoard/rust/src/webserver/mod.rs +++ b/Software/MainBoard/rust/src/webserver/mod.rs @@ -1,7 +1,6 @@ //offer ota and config mode mod backup_manager; -mod file_manager; mod get_json; mod get_log; mod get_static; @@ -10,10 +9,9 @@ mod post_json; use crate::fat_error::{FatError, FatResult}; use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config}; -use crate::webserver::file_manager::{file_operations, list_files}; use crate::webserver::get_json::{ get_battery_state, get_config, get_live_moisture, get_log_localization_config, get_solar_state, - get_time, get_timezones, get_version_web, tank_info, + delete_save, 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}; @@ -60,10 +58,7 @@ impl Handler for HTTPRequestRouter { let method = headers.method; let path = headers.path; - let prefix = "/file?filename="; - let status = if path.starts_with(prefix) { - file_operations(conn, method, &path, &prefix).await? - } else if path == "/ota" { + let status = if path == "/ota" { ota_operations(conn, method).await.map_err(|e| { error!("Error handling ota: {e}"); e @@ -82,13 +77,20 @@ impl Handler for HTTPRequestRouter { "/time" => Some(get_time(conn).await), "/battery" => Some(get_battery_state(conn).await), "/solar" => Some(get_solar_state(conn).await), - "/get_config" => Some(get_config(conn).await), - "/files" => Some(list_files(conn).await), "/log_localization" => Some(get_log_localization_config(conn).await), "/tank" => Some(tank_info(conn).await), "/backup_info" => Some(backup_info(conn).await), "/timezones" => Some(get_timezones().await), "/moisture" => Some(get_live_moisture(conn).await), + "/list_saves" => Some(list_saves(conn).await), + // /get_config accepts an optional ?saveidx=N query parameter + p if p == "/get_config" || p.starts_with("/get_config?") => { + let saveidx: Option = p + .find("saveidx=") + .and_then(|pos| p[pos + 8..].split('&').next()) + .and_then(|s| s.parse().ok()); + Some(get_config(conn, saveidx).await) + } _ => None, }; match json { @@ -127,7 +129,26 @@ impl Handler for HTTPRequestRouter { Some(json) => Some(handle_json(conn, json).await?), } } - Method::Options | Method::Delete | Method::Head | Method::Put => None, + Method::Delete => { + // DELETE /delete_save?idx=N + let json = if path == "/delete_save" || path.starts_with("/delete_save?") { + let idx: Option = path + .find("idx=") + .and_then(|pos| path[pos + 4..].split('&').next()) + .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() })), + } + } else { + None + }; + match json { + None => None, + Some(json) => Some(handle_json(conn, json).await?), + } + } + Method::Options | Method::Head | Method::Put => None, _ => None, } }; diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index 876390b..10497a7 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -34,6 +34,11 @@ export interface NetworkConfig { max_wait: number } +export interface SaveInfo { + idx: number, + len: number, +} + export interface FileList { total: number, used: number, diff --git a/Software/MainBoard/rust/src_webpack/src/fileview.html b/Software/MainBoard/rust/src_webpack/src/fileview.html index 3c7f49a..eae1b2f 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileview.html +++ b/Software/MainBoard/rust/src_webpack/src/fileview.html @@ -29,44 +29,10 @@ } -
Files:
-
-
Total Size
-
-
-
-
Used Size
-
-
-
-
Free Size
-
-
-
- -
-
Upload:
-
-
-
-
- File: -
- -
-
-
- Name: -
- -
-
-
- -
+
Save Slots:

List:
-
\ No newline at end of file + diff --git a/Software/MainBoard/rust/src_webpack/src/fileview.ts b/Software/MainBoard/rust/src_webpack/src/fileview.ts index 4a30f72..7952f64 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileview.ts +++ b/Software/MainBoard/rust/src_webpack/src/fileview.ts @@ -1,96 +1,49 @@ import {Controller} from "./main"; -import {FileInfo, FileList} from "./api"; -const regex = /[^a-zA-Z0-9_.]/g; - -function sanitize(str:string){ - return str.replaceAll(regex, '_') -} +import {SaveInfo} from "./api"; export class FileView { readonly fileListView: HTMLElement; readonly controller: Controller; - readonly filefreesize: HTMLElement; - readonly filetotalsize: HTMLElement; - readonly fileusedsize: HTMLElement; constructor(controller: Controller) { (document.getElementById("fileview") as HTMLElement).innerHTML = require('./fileview.html') as string; - this.fileListView = document.getElementById("fileList") as HTMLElement - this.filefreesize = document.getElementById("filefreesize") as HTMLElement - this.filetotalsize = document.getElementById("filetotalsize") as HTMLElement - this.fileusedsize = document.getElementById("fileusedsize") as HTMLElement - - let fileuploadfile = document.getElementById("fileuploadfile") as HTMLInputElement - let fileuploadname = document.getElementById("fileuploadname") as HTMLInputElement - let fileuploadbtn = document.getElementById("fileuploadbtn") as HTMLInputElement - fileuploadfile.onchange = () => { - const selectedFile = fileuploadfile.files?.[0]; - if (selectedFile == null) { - //TODO error dialog here - return - } - - fileuploadname.value = sanitize(selectedFile.name) - }; - - fileuploadname.onchange = () => { - let input = fileuploadname.value - let clean = sanitize(fileuploadname.value) - if (input != clean){ - fileuploadname.value = clean - } - } - - fileuploadbtn.onclick = () => { - const selectedFile = fileuploadfile.files?.[0]; - if (selectedFile == null) { - //TODO error dialog here - return - } - controller.uploadFile(selectedFile, selectedFile.name) - } - - - + this.fileListView = document.getElementById("fileList") as HTMLElement; this.controller = controller; } - setFileList(fileList: FileList, public_url: string) { - this.filetotalsize.innerText = Math.floor(fileList.total / 1024) + "kB" - this.fileusedsize.innerText = Math.ceil(fileList.used / 1024) + "kB" - this.filefreesize.innerText = Math.ceil((fileList.total - fileList.used) / 1024) + "kB" + setSaveList(saves: SaveInfo[], public_url: string) { + // Sort newest first (highest index = most recently written slot) + const sorted = saves.slice().sort((a, b) => b.idx - a.idx); - //fast clear - this.fileListView.textContent = "" - for (let i = 0; i < fileList.files.length; i++) { - let file = fileList.files[i] - new FileEntry(this.controller, i, file, this.fileListView, public_url); + this.fileListView.textContent = ""; + for (let i = 0; i < sorted.length; i++) { + new SaveEntry(this.controller, i, sorted[i], this.fileListView, public_url); } } } -class FileEntry { +class SaveEntry { view: HTMLElement; - constructor(controller: Controller, fileid: number, fileinfo: FileInfo, parent: HTMLElement, public_url: string) { - this.view = document.createElement("div") as HTMLElement - parent.appendChild(this.view) - this.view.classList.add("fileentryouter") + constructor(controller: Controller, fileid: number, saveinfo: SaveInfo, parent: HTMLElement, public_url: string) { + this.view = document.createElement("div") as HTMLElement; + parent.appendChild(this.view); + this.view.classList.add("fileentryouter"); const template = require('./fileviewentry.html') as string; - 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 size = document.getElementById("file_" + fileid + "_size") as HTMLElement; let deleteBtn = document.getElementById("file_" + fileid + "_delete") as HTMLButtonElement; deleteBtn.onclick = () => { - controller.deleteFile(fileinfo.filename); - } + controller.deleteSlot(saveinfo.idx); + }; let downloadBtn = document.getElementById("file_" + fileid + "_download") as HTMLAnchorElement; - downloadBtn.href = public_url + "/file?filename=" + fileinfo.filename - downloadBtn.download = fileinfo.filename + downloadBtn.href = public_url + "/get_config?saveidx=" + saveinfo.idx; + downloadBtn.download = "config_slot_" + saveinfo.idx + ".json"; - name.innerText = fileinfo.filename; - size.innerText = fileinfo.size.toString() + name.innerText = "Slot " + saveinfo.idx; + size.innerText = saveinfo.len + " bytes"; } -} \ No newline at end of file +} diff --git a/Software/MainBoard/rust/src_webpack/src/fileviewentry.html b/Software/MainBoard/rust/src_webpack/src/fileviewentry.html index b602480..9f0cc96 100644 --- a/Software/MainBoard/rust/src_webpack/src/fileviewentry.html +++ b/Software/MainBoard/rust/src_webpack/src/fileviewentry.html @@ -1,11 +1,12 @@
-
Name
+
Slot
Size
-
- Download - +
+ Download +
+ diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index 5469923..9c5ac7f 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -29,7 +29,7 @@ import { SetTime, SSIDList, TankInfo, TestPump, VersionInfo, - FileList, SolarState, PumpTestResult, Detection, CanPower + SaveInfo, SolarState, PumpTestResult, Detection, CanPower } from "./api"; import {SolarView} from "./solarview"; import {toast} from "./toast"; @@ -93,65 +93,36 @@ export class Controller { } } - async updateFileList(): Promise { + async updateSaveList(): Promise { try { - const response = await fetch(PUBLIC_URL + "/files"); + const response = await fetch(PUBLIC_URL + "/list_saves"); const json = await response.json(); - const filelist = json as FileList; - controller.fileview.setFileList(filelist, PUBLIC_URL); + const saves = json as SaveInfo[]; + controller.fileview.setSaveList(saves, PUBLIC_URL); } catch (error) { console.log(error); } } - uploadFile(file: File, name: string) { - let current = 0; - let max = 100; - controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")") + deleteSlot(idx: number) { + controller.progressview.addIndeterminate("slot_delete", "Deleting slot " + idx); const ajax = new XMLHttpRequest(); - ajax.upload.addEventListener("progress", event => { - current = event.loaded / 1000; - max = event.total / 1000; - controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")") - }, false); - ajax.addEventListener("load", () => { - controller.progressview.removeProgress("file_upload") - controller.updateFileList() - }, false); - ajax.addEventListener("error", () => { - alert("Error upload") - controller.progressview.removeProgress("file_upload") - controller.updateFileList() - }, false); - ajax.addEventListener("abort", () => { - alert("abort upload") - controller.progressview.removeProgress("file_upload") - controller.updateFileList() - }, false); - ajax.open("POST", PUBLIC_URL + "/file?filename=" + name); - ajax.send(file); - } - - deleteFile(name: string) { - controller.progressview.addIndeterminate("file_delete", "Deleting " + name); - const ajax = new XMLHttpRequest(); - ajax.open("DELETE", PUBLIC_URL + "/file?filename=" + name); + ajax.open("DELETE", PUBLIC_URL + "/delete_save?idx=" + idx); ajax.send(); ajax.addEventListener("error", () => { - controller.progressview.removeProgress("file_delete") - alert("Error delete") - controller.updateFileList() + controller.progressview.removeProgress("slot_delete"); + alert("Error deleting slot"); + controller.updateSaveList(); }, false); ajax.addEventListener("abort", () => { - controller.progressview.removeProgress("file_delete") - alert("Error upload") - controller.updateFileList() + controller.progressview.removeProgress("slot_delete"); + alert("Aborted deleting slot"); + controller.updateSaveList(); }, false); ajax.addEventListener("load", () => { - controller.progressview.removeProgress("file_delete") - controller.updateFileList() + controller.progressview.removeProgress("slot_delete"); + controller.updateSaveList(); }, false); - controller.updateFileList() } async updateRTCData(): Promise { @@ -668,7 +639,7 @@ const tasks = [ {task: controller.updateSolarData, displayString: "Updating Solar Data"}, {task: controller.downloadConfig, displayString: "Downloading Configuration"}, {task: controller.version, displayString: "Fetching Version Information"}, - {task: controller.updateFileList, displayString: "Updating File List"}, + {task: controller.updateSaveList, displayString: "Updating Save Slots"}, {task: controller.getBackupInfo, displayString: "Fetching Backup Information"}, {task: controller.loadLogLocaleConfig, displayString: "Loading Log Localization Config"}, {task: controller.loadTankInfo, displayString: "Loading Tank Information"}, diff --git a/website/themes/blowfish b/website/themes/blowfish index 26d1205..f9eb1d4 160000 --- a/website/themes/blowfish +++ b/website/themes/blowfish @@ -1 +1 @@ -Subproject commit 26d1205439b460bee960fd4c29f3c5c20948875f +Subproject commit f9eb1d4e811d6da744848c35fb842cf386f6df39