remove: eliminate file management and LittleFS-based filesystem, implement savegame management for JSON config slots with wear-leveling
This commit is contained in:
@@ -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"
|
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 }
|
CARGO_WORKSPACE_DIR = { value = "", relative = true }
|
||||||
ESP_LOG = "info"
|
ESP_LOG = "info"
|
||||||
PATH = { value = "../../../bin:/usr/bin:/usr/local/bin", force = true, relative = true }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[unstable]
|
[unstable]
|
||||||
|
|||||||
@@ -109,8 +109,7 @@ pca9535 = { version = "2.0.0" }
|
|||||||
ina219 = { version = "0.2.0" }
|
ina219 = { version = "0.2.0" }
|
||||||
|
|
||||||
# Storage and filesystem
|
# Storage and filesystem
|
||||||
littlefs2 = { version = "0.6.1", features = ["c-stubs", "alloc"] }
|
embedded-savegame = { version = "0.3.0" }
|
||||||
littlefs2-core = "0.1.2"
|
|
||||||
|
|
||||||
# Serialization / codecs
|
# Serialization / codecs
|
||||||
serde = { version = "1.0.228", features = ["derive", "alloc"], default-features = false }
|
serde = { version = "1.0.228", features = ["derive", "alloc"], default-features = false }
|
||||||
|
|||||||
@@ -16,28 +16,18 @@ use esp_hal::twai::EspTwaiError;
|
|||||||
use esp_radio::wifi::WifiError;
|
use esp_radio::wifi::WifiError;
|
||||||
use ina219::errors::{BusVoltageReadError, ShuntVoltageReadError};
|
use ina219::errors::{BusVoltageReadError, ShuntVoltageReadError};
|
||||||
use lib_bms_protocol::BmsProtocolError;
|
use lib_bms_protocol::BmsProtocolError;
|
||||||
use littlefs2_core::PathError;
|
|
||||||
use onewire::Error;
|
use onewire::Error;
|
||||||
use pca9535::ExpanderError;
|
use pca9535::ExpanderError;
|
||||||
|
|
||||||
//All error superconstruct
|
//All error superconstruct
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum FatError {
|
pub enum FatError {
|
||||||
BMSError {
|
|
||||||
error: String,
|
|
||||||
},
|
|
||||||
OneWireError {
|
OneWireError {
|
||||||
error: Error<Infallible>,
|
error: Error<Infallible>,
|
||||||
},
|
},
|
||||||
String {
|
String {
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
LittleFSError {
|
|
||||||
error: littlefs2_core::Error,
|
|
||||||
},
|
|
||||||
PathError {
|
|
||||||
error: PathError,
|
|
||||||
},
|
|
||||||
TryLockError {
|
TryLockError {
|
||||||
error: TryLockError,
|
error: TryLockError,
|
||||||
},
|
},
|
||||||
@@ -88,8 +78,6 @@ impl fmt::Display for FatError {
|
|||||||
}
|
}
|
||||||
FatError::OneWireError { error } => write!(f, "OneWireError {error:?}"),
|
FatError::OneWireError { error } => write!(f, "OneWireError {error:?}"),
|
||||||
FatError::String { error } => write!(f, "{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::TryLockError { error } => write!(f, "TryLockError {error:?}"),
|
||||||
FatError::WifiError { error } => write!(f, "WifiError {error:?}"),
|
FatError::WifiError { error } => write!(f, "WifiError {error:?}"),
|
||||||
FatError::SerdeError { error } => write!(f, "SerdeError {error:?}"),
|
FatError::SerdeError { error } => write!(f, "SerdeError {error:?}"),
|
||||||
@@ -108,7 +96,6 @@ impl fmt::Display for FatError {
|
|||||||
write!(f, "CanBusError {error:?}")
|
write!(f, "CanBusError {error:?}")
|
||||||
}
|
}
|
||||||
FatError::SNTPError { error } => write!(f, "SNTPError {error:?}"),
|
FatError::SNTPError { error } => write!(f, "SNTPError {error:?}"),
|
||||||
FatError::BMSError { error } => write!(f, "BMSError, {error}"),
|
|
||||||
FatError::OTAError => {
|
FatError::OTAError => {
|
||||||
write!(f, "OTA missing partition")
|
write!(f, "OTA missing partition")
|
||||||
}
|
}
|
||||||
@@ -173,18 +160,6 @@ impl From<Error<Infallible>> for FatError {
|
|||||||
FatError::OneWireError { error }
|
FatError::OneWireError { error }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl From<littlefs2_core::Error> for FatError {
|
|
||||||
fn from(value: littlefs2_core::Error) -> Self {
|
|
||||||
FatError::LittleFSError { error: value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PathError> for FatError {
|
|
||||||
fn from(value: PathError) -> Self {
|
|
||||||
FatError::PathError { error: value }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TryLockError> for FatError {
|
impl From<TryLockError> for FatError {
|
||||||
fn from(value: TryLockError) -> Self {
|
fn from(value: TryLockError) -> Self {
|
||||||
FatError::TryLockError { error: value }
|
FatError::TryLockError { error: value }
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
use crate::bail;
|
use crate::bail;
|
||||||
use crate::config::{NetworkConfig, PlantControllerConfig};
|
use crate::config::{NetworkConfig, PlantControllerConfig};
|
||||||
|
use crate::hal::savegame_manager::SavegameManager;
|
||||||
use crate::hal::{PLANT_COUNT, TIME_ACCESS};
|
use crate::hal::{PLANT_COUNT, TIME_ACCESS};
|
||||||
use crate::log::{LogMessage, LOG_ACCESS};
|
use crate::log::{LogMessage, LOG_ACCESS};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::fat_error::{ContextExt, FatError, FatResult};
|
use crate::fat_error::{ContextExt, FatError, FatResult};
|
||||||
use crate::hal::little_fs2storage_adapter::LittleFs2Filesystem;
|
|
||||||
use crate::hal::shared_flash::MutexFlashStorage;
|
use crate::hal::shared_flash::MutexFlashStorage;
|
||||||
use alloc::string::ToString;
|
use alloc::string::ToString;
|
||||||
use alloc::sync::Arc;
|
use alloc::sync::Arc;
|
||||||
use alloc::{format, string::String, vec, vec::Vec};
|
use alloc::{format, string::String, vec, vec::Vec};
|
||||||
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
|
use core::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
use core::str::FromStr;
|
|
||||||
use core::sync::atomic::Ordering;
|
use core::sync::atomic::Ordering;
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
use embassy_net::udp::UdpSocket;
|
use embassy_net::udp::UdpSocket;
|
||||||
@@ -39,8 +37,6 @@ use esp_radio::wifi::{
|
|||||||
AccessPointConfig, AccessPointInfo, AuthMethod, ClientConfig, ModeConfig, ScanConfig,
|
AccessPointConfig, AccessPointInfo, AuthMethod, ClientConfig, ModeConfig, ScanConfig,
|
||||||
ScanTypeConfig, WifiController, WifiDevice, WifiStaState,
|
ScanTypeConfig, WifiController, WifiDevice, WifiStaState,
|
||||||
};
|
};
|
||||||
use littlefs2::fs::Filesystem;
|
|
||||||
use littlefs2_core::{FileType, PathBuf, SeekFrom};
|
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use mcutie::{
|
use mcutie::{
|
||||||
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
|
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))]
|
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
|
||||||
static mut RESTART_TO_CONF: i8 = 0;
|
static mut RESTART_TO_CONF: i8 = 0;
|
||||||
|
|
||||||
const CONFIG_FILE: &str = "config.json";
|
|
||||||
const NTP_SERVER: &str = "pool.ntp.org";
|
const NTP_SERVER: &str = "pool.ntp.org";
|
||||||
|
|
||||||
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
|
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);
|
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
|
||||||
static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new();
|
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)]
|
#[derive(Copy, Clone, Default)]
|
||||||
struct Timestamp {
|
struct Timestamp {
|
||||||
stamp: DateTime<Utc>,
|
stamp: DateTime<Utc>,
|
||||||
@@ -117,7 +99,7 @@ impl NtpTimestampGenerator for Timestamp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Esp<'a> {
|
pub struct Esp<'a> {
|
||||||
pub fs: Arc<Mutex<CriticalSectionRawMutex, Filesystem<'static, LittleFs2Filesystem>>>,
|
pub savegame: SavegameManager,
|
||||||
pub rng: Rng,
|
pub rng: Rng,
|
||||||
//first starter (ap or sta will take these)
|
//first starter (ap or sta will take these)
|
||||||
pub interface_sta: Option<WifiDevice<'static>>,
|
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> {
|
pub(crate) async fn write_ota(&mut self, offset: u32, buf: &[u8]) -> Result<(), FatError> {
|
||||||
let _ = check_erase(self.ota_target, offset, offset + 4096);
|
let _ = check_erase(self.ota_target, offset, offset + 4096);
|
||||||
@@ -422,10 +341,7 @@ impl Esp<'_> {
|
|||||||
.interface_ap
|
.interface_ap
|
||||||
.take()
|
.take()
|
||||||
.context("AP interface already taken")?;
|
.context("AP interface already taken")?;
|
||||||
let gw_ip_addr_str = "192.168.71.1";
|
let gw_ip_addr = Ipv4Addr::new(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 config = embassy_net::Config::ipv4_static(StaticConfigV4 {
|
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
|
||||||
address: Ipv4Cidr::new(gw_ip_addr, 24),
|
address: Ipv4Cidr::new(gw_ip_addr, 24),
|
||||||
@@ -454,7 +370,7 @@ impl Esp<'_> {
|
|||||||
println!("start net task");
|
println!("start net task");
|
||||||
spawner.spawn(net_task(runner)).ok();
|
spawner.spawn(net_task(runner)).ok();
|
||||||
println!("run dhcp");
|
println!("run dhcp");
|
||||||
spawner.spawn(run_dhcp(*stack, gw_ip_addr_str)).ok();
|
spawner.spawn(run_dhcp(*stack, gw_ip_addr)).ok();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if stack.is_link_up() {
|
if stack.is_link_up() {
|
||||||
@@ -465,7 +381,7 @@ impl Esp<'_> {
|
|||||||
while !stack.is_config_up() {
|
while !stack.is_config_up() {
|
||||||
Timer::after(Duration::from_millis(100)).await
|
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
|
stack
|
||||||
.config_v4()
|
.config_v4()
|
||||||
.inspect(|c| println!("ipv4 config: {c:?}"));
|
.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> {
|
pub(crate) async fn load_config(&mut self) -> FatResult<PlantControllerConfig> {
|
||||||
let cfg = PathBuf::try_from(CONFIG_FILE)?;
|
match self.savegame.load_latest()? {
|
||||||
let config_exist = self.fs.lock().await.exists(&cfg);
|
None => bail!("No config stored"),
|
||||||
if !config_exist {
|
Some(data) => {
|
||||||
bail!("No config file stored")
|
|
||||||
}
|
|
||||||
let data = self.fs.lock().await.read::<4096>(&cfg)?;
|
|
||||||
let config: PlantControllerConfig = serde_json::from_slice(&data)?;
|
let config: PlantControllerConfig = serde_json::from_slice(&data)?;
|
||||||
Ok(config)
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
pub(crate) async fn list_files(&self) -> FatResult<FileList> {
|
|
||||||
let path = PathBuf::new();
|
|
||||||
|
|
||||||
let fs = self.fs.lock().await;
|
/// Delete a specific save slot by erasing it on flash.
|
||||||
let free_size = fs.available_space()?;
|
pub(crate) async fn delete_save_slot(&mut self, idx: usize) -> FatResult<()> {
|
||||||
let total_size = fs.total_space();
|
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(())
|
/// Return metadata about all valid save slots.
|
||||||
})?;
|
pub(crate) async fn list_saves(
|
||||||
Ok(result)
|
&mut self,
|
||||||
|
) -> FatResult<alloc::vec::Vec<crate::hal::savegame_manager::SaveInfo>> {
|
||||||
|
self.savegame.list_saves()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn init_rtc_deepsleep_memory(
|
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]
|
#[embassy_executor::task]
|
||||||
async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
|
async fn run_dhcp(stack: Stack<'static>, ip: Ipv4Addr) {
|
||||||
use core::net::{Ipv4Addr, SocketAddrV4};
|
use core::net::SocketAddrV4;
|
||||||
|
|
||||||
use edge_dhcp::{
|
use edge_dhcp::{
|
||||||
io::{self, DEFAULT_SERVER_PORT},
|
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::UdpBind;
|
||||||
use edge_nal_embassy::{Udp, UdpBuffers};
|
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 buf = [0u8; 1500];
|
||||||
|
|
||||||
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];
|
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ use lib_bms_protocol::BmsReadable;
|
|||||||
pub(crate) mod battery;
|
pub(crate) mod battery;
|
||||||
// mod can_api; // replaced by external canapi crate
|
// mod can_api; // replaced by external canapi crate
|
||||||
pub mod esp;
|
pub mod esp;
|
||||||
mod little_fs2storage_adapter;
|
pub(crate) mod savegame_manager;
|
||||||
pub(crate) mod rtc;
|
pub(crate) mod rtc;
|
||||||
mod shared_flash;
|
mod shared_flash;
|
||||||
mod v4_hal;
|
mod v4_hal;
|
||||||
@@ -75,7 +75,7 @@ use measurements::{Current, Voltage};
|
|||||||
|
|
||||||
use crate::fat_error::{ContextExt, FatError, FatResult};
|
use crate::fat_error::{ContextExt, FatError, FatResult};
|
||||||
use crate::hal::battery::WCHI2CSlave;
|
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::hal::water::TankSensor;
|
||||||
use crate::log::LOG_ACCESS;
|
use crate::log::LOG_ACCESS;
|
||||||
use embassy_sync::mutex::Mutex;
|
use embassy_sync::mutex::Mutex;
|
||||||
@@ -99,9 +99,7 @@ use esp_hal::uart::Uart;
|
|||||||
use esp_hal::Blocking;
|
use esp_hal::Blocking;
|
||||||
use esp_radio::{init, Controller};
|
use esp_radio::{init, Controller};
|
||||||
use esp_storage::FlashStorage;
|
use esp_storage::FlashStorage;
|
||||||
use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem};
|
use log::{info, warn};
|
||||||
use littlefs2::object_safe::DynStorage;
|
|
||||||
use log::{error, info, warn};
|
|
||||||
use portable_atomic::AtomicBool;
|
use portable_atomic::AtomicBool;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use shared_flash::MutexFlashStorage;
|
use shared_flash::MutexFlashStorage;
|
||||||
@@ -379,33 +377,16 @@ impl PlantHal {
|
|||||||
.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data(
|
.find_partition(esp_bootloader_esp_idf::partitions::PartitionType::Data(
|
||||||
DataPartitionSubType::LittleFs,
|
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_partition = mk_static!(PartitionEntry, data_partition);
|
||||||
|
|
||||||
let data = mk_static!(
|
let data = mk_static!(
|
||||||
FlashRegion<MutexFlashStorage>,
|
FlashRegion<'static, MutexFlashStorage>,
|
||||||
data_partition.as_embedded_storage(flash_storage_3)
|
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 savegame = SavegameManager::new(data);
|
||||||
let fs = Arc::new(Mutex::new(
|
info!("Savegame storage initialized ({} slots × {} KB)", savegame_manager::SAVEGAME_SLOT_COUNT, savegame_manager::SAVEGAME_SLOT_SIZE / 1024);
|
||||||
lfs2Filesystem::mount(alloc, lfs2filesystem)
|
|
||||||
.context("Could not mount lfs2 filesystem")?,
|
|
||||||
));
|
|
||||||
|
|
||||||
let uart0 =
|
let uart0 =
|
||||||
Uart::new(peripherals.UART0, UartConfig::default()).map_err(|_| FatError::String {
|
Uart::new(peripherals.UART0, UartConfig::default()).map_err(|_| FatError::String {
|
||||||
@@ -415,7 +396,7 @@ impl PlantHal {
|
|||||||
let ap = interfaces.ap;
|
let ap = interfaces.ap;
|
||||||
let sta = interfaces.sta;
|
let sta = interfaces.sta;
|
||||||
let mut esp = Esp {
|
let mut esp = Esp {
|
||||||
fs,
|
savegame,
|
||||||
rng,
|
rng,
|
||||||
controller: Arc::new(Mutex::new(controller)),
|
controller: Arc::new(Mutex::new(controller)),
|
||||||
interface_sta: Some(sta),
|
interface_sta: Some(sta),
|
||||||
|
|||||||
150
Software/MainBoard/rust/src/hal/savegame_manager.rs
Normal file
150
Software/MainBoard/rust/src/hal/savegame_manager.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,19 +340,12 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
|
|||||||
bail!("pump current sensor not available");
|
bail!("pump current sensor not available");
|
||||||
}
|
}
|
||||||
Some(pump_ina) => {
|
Some(pump_ina) => {
|
||||||
let v = pump_ina
|
let raw = pump_ina.shunt_voltage()?;
|
||||||
.shunt_voltage()
|
|
||||||
.map_err(|e| FatError::String {
|
|
||||||
error: alloc::format!("{e:?}"),
|
|
||||||
})
|
|
||||||
.map(|v| {
|
|
||||||
let shunt_voltage =
|
let shunt_voltage =
|
||||||
Voltage::from_microvolts(v.shunt_voltage_uv().abs() as f64);
|
Voltage::from_microvolts(raw.shunt_voltage_uv().abs() as f64);
|
||||||
let shut_value = Resistance::from_ohms(0.05_f64);
|
let shut_value = Resistance::from_ohms(0.05_f64);
|
||||||
let current = shunt_voltage.as_volts() / shut_value.as_ohms();
|
let current = shunt_voltage.as_volts() / shut_value.as_ohms();
|
||||||
Current::from_amperes(current)
|
Ok(Current::from_amperes(current))
|
||||||
})?;
|
|
||||||
Ok(v)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,10 +602,8 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
|
|||||||
let info: Result<(BackupHeader, usize), bincode::error::DecodeError> =
|
let info: Result<(BackupHeader, usize), bincode::error::DecodeError> =
|
||||||
bincode::decode_from_slice(&header_page_buffer[..], CONFIG);
|
bincode::decode_from_slice(&header_page_buffer[..], CONFIG);
|
||||||
info!("decoding header: {:?}", info);
|
info!("decoding header: {:?}", info);
|
||||||
info.map(|(header, _)| header)
|
let (header, _) = info.context("Could not read backup header")?;
|
||||||
.map_err(|e| FatError::String {
|
Ok(header)
|
||||||
error: "Could not read backup header: ".to_string() + &e.to_string(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<T, const N: usize>(
|
|
||||||
_request: &mut Connection<'_, T, N>,
|
|
||||||
) -> FatResult<Option<String>> {
|
|
||||||
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<T, const N: usize>(
|
|
||||||
conn: &mut Connection<'_, T, { N }>,
|
|
||||||
method: Method,
|
|
||||||
path: &&str,
|
|
||||||
prefix: &&str,
|
|
||||||
) -> Result<Option<u32>, 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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -114,12 +114,39 @@ pub(crate) async fn get_version_web<T, const N: usize>(
|
|||||||
Ok(Some(serde_json::to_string(&get_version(&mut board).await)?))
|
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<T, const N: usize>(
|
pub(crate) async fn get_config<T, const N: usize>(
|
||||||
_request: &mut Connection<'_, T, N>,
|
_request: &mut Connection<'_, T, N>,
|
||||||
|
saveidx: Option<usize>,
|
||||||
|
) -> FatResult<Option<String>> {
|
||||||
|
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<T, const N: usize>(
|
||||||
|
_request: &mut Connection<'_, T, N>,
|
||||||
) -> FatResult<Option<String>> {
|
) -> FatResult<Option<String>> {
|
||||||
let mut board = BOARD_ACCESS.get().await.lock().await;
|
let mut board = BOARD_ACCESS.get().await.lock().await;
|
||||||
let json = serde_json::to_string(&board.board_hal.get_config())?;
|
let saves = board.board_hal.get_esp().list_saves().await?;
|
||||||
Ok(Some(json))
|
Ok(Some(serde_json::to_string(&saves)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erase (delete) a single save slot by index.
|
||||||
|
pub(crate) async fn delete_save<T, const N: usize>(
|
||||||
|
_request: &mut Connection<'_, T, N>,
|
||||||
|
idx: usize,
|
||||||
|
) -> FatResult<Option<String>> {
|
||||||
|
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<T, const N: usize>(
|
pub(crate) async fn get_battery_state<T, const N: usize>(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
//offer ota and config mode
|
//offer ota and config mode
|
||||||
|
|
||||||
mod backup_manager;
|
mod backup_manager;
|
||||||
mod file_manager;
|
|
||||||
mod get_json;
|
mod get_json;
|
||||||
mod get_log;
|
mod get_log;
|
||||||
mod get_static;
|
mod get_static;
|
||||||
@@ -10,10 +9,9 @@ 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::file_manager::{file_operations, list_files};
|
|
||||||
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,
|
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_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};
|
||||||
@@ -60,10 +58,7 @@ impl Handler for HTTPRequestRouter {
|
|||||||
let method = headers.method;
|
let method = headers.method;
|
||||||
let path = headers.path;
|
let path = headers.path;
|
||||||
|
|
||||||
let prefix = "/file?filename=";
|
let status = if path == "/ota" {
|
||||||
let status = if path.starts_with(prefix) {
|
|
||||||
file_operations(conn, method, &path, &prefix).await?
|
|
||||||
} else if path == "/ota" {
|
|
||||||
ota_operations(conn, method).await.map_err(|e| {
|
ota_operations(conn, method).await.map_err(|e| {
|
||||||
error!("Error handling ota: {e}");
|
error!("Error handling ota: {e}");
|
||||||
e
|
e
|
||||||
@@ -82,13 +77,20 @@ impl Handler for HTTPRequestRouter {
|
|||||||
"/time" => Some(get_time(conn).await),
|
"/time" => Some(get_time(conn).await),
|
||||||
"/battery" => Some(get_battery_state(conn).await),
|
"/battery" => Some(get_battery_state(conn).await),
|
||||||
"/solar" => Some(get_solar_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),
|
"/log_localization" => Some(get_log_localization_config(conn).await),
|
||||||
"/tank" => Some(tank_info(conn).await),
|
"/tank" => Some(tank_info(conn).await),
|
||||||
"/backup_info" => Some(backup_info(conn).await),
|
"/backup_info" => Some(backup_info(conn).await),
|
||||||
"/timezones" => Some(get_timezones().await),
|
"/timezones" => Some(get_timezones().await),
|
||||||
"/moisture" => Some(get_live_moisture(conn).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<usize> = p
|
||||||
|
.find("saveidx=")
|
||||||
|
.and_then(|pos| p[pos + 8..].split('&').next())
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
Some(get_config(conn, saveidx).await)
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
match json {
|
match json {
|
||||||
@@ -127,7 +129,26 @@ impl Handler for HTTPRequestRouter {
|
|||||||
Some(json) => Some(handle_json(conn, json).await?),
|
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<usize> = 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,
|
_ => None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export interface NetworkConfig {
|
|||||||
max_wait: number
|
max_wait: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveInfo {
|
||||||
|
idx: number,
|
||||||
|
len: number,
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileList {
|
export interface FileList {
|
||||||
total: number,
|
total: number,
|
||||||
used: number,
|
used: number,
|
||||||
|
|||||||
@@ -29,41 +29,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="subtitle">Files:</div>
|
<div class="subtitle">Save Slots:</div>
|
||||||
<div class="flexcontainer">
|
|
||||||
<div class="filekey">Total Size</div>
|
|
||||||
<div id="filetotalsize" class="filevalue"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flexcontainer">
|
|
||||||
<div class="filekey">Used Size</div>
|
|
||||||
<div id="fileusedsize" class="filevalue"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flexcontainer">
|
|
||||||
<div class="filekey">Free Size</div>
|
|
||||||
<div id="filefreesize" class="filevalue"></div>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-top-style: double;">
|
|
||||||
<div class="subtitle" >Upload:</div>
|
|
||||||
</div>
|
|
||||||
<div class="flexcontainer" style="border-left-style: double; border-right-style: double;">
|
|
||||||
<div class="flexcontainer">
|
|
||||||
<div class="filekey">
|
|
||||||
File:
|
|
||||||
</div>
|
|
||||||
<input id="fileuploadfile" class="filevalue" type="file">
|
|
||||||
</div>
|
|
||||||
<div class="flexcontainer">
|
|
||||||
<div class="filekey">
|
|
||||||
Name:
|
|
||||||
</div>
|
|
||||||
<input id="fileuploadname" class="filevalue" type="text">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-bottom-style: double;">
|
|
||||||
<button id="fileuploadbtn" class="subtitle">Upload</button>
|
|
||||||
</div>
|
|
||||||
<br>
|
<br>
|
||||||
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-top-style: double;">
|
<div class="flexcontainer" style="border-left-style: double; border-right-style: double; border-top-style: double;">
|
||||||
<div class="subtitle">List:</div>
|
<div class="subtitle">List:</div>
|
||||||
|
|||||||
@@ -1,96 +1,49 @@
|
|||||||
import {Controller} from "./main";
|
import {Controller} from "./main";
|
||||||
import {FileInfo, FileList} from "./api";
|
import {SaveInfo} from "./api";
|
||||||
const regex = /[^a-zA-Z0-9_.]/g;
|
|
||||||
|
|
||||||
function sanitize(str:string){
|
|
||||||
return str.replaceAll(regex, '_')
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileView {
|
export class FileView {
|
||||||
readonly fileListView: HTMLElement;
|
readonly fileListView: HTMLElement;
|
||||||
readonly controller: Controller;
|
readonly controller: Controller;
|
||||||
readonly filefreesize: HTMLElement;
|
|
||||||
readonly filetotalsize: HTMLElement;
|
|
||||||
readonly fileusedsize: HTMLElement;
|
|
||||||
|
|
||||||
constructor(controller: Controller) {
|
constructor(controller: Controller) {
|
||||||
(document.getElementById("fileview") as HTMLElement).innerHTML = require('./fileview.html') as string;
|
(document.getElementById("fileview") as HTMLElement).innerHTML = require('./fileview.html') as string;
|
||||||
this.fileListView = document.getElementById("fileList") as HTMLElement
|
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.controller = controller;
|
this.controller = controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFileList(fileList: FileList, public_url: string) {
|
setSaveList(saves: SaveInfo[], public_url: string) {
|
||||||
this.filetotalsize.innerText = Math.floor(fileList.total / 1024) + "kB"
|
// Sort newest first (highest index = most recently written slot)
|
||||||
this.fileusedsize.innerText = Math.ceil(fileList.used / 1024) + "kB"
|
const sorted = saves.slice().sort((a, b) => b.idx - a.idx);
|
||||||
this.filefreesize.innerText = Math.ceil((fileList.total - fileList.used) / 1024) + "kB"
|
|
||||||
|
|
||||||
//fast clear
|
this.fileListView.textContent = "";
|
||||||
this.fileListView.textContent = ""
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
for (let i = 0; i < fileList.files.length; i++) {
|
new SaveEntry(this.controller, i, sorted[i], this.fileListView, public_url);
|
||||||
let file = fileList.files[i]
|
|
||||||
new FileEntry(this.controller, i, file, this.fileListView, public_url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileEntry {
|
class SaveEntry {
|
||||||
view: HTMLElement;
|
view: HTMLElement;
|
||||||
constructor(controller: Controller, fileid: number, fileinfo: FileInfo, parent: HTMLElement, public_url: string) {
|
constructor(controller: Controller, fileid: number, saveinfo: SaveInfo, parent: HTMLElement, public_url: string) {
|
||||||
this.view = document.createElement("div") as HTMLElement
|
this.view = document.createElement("div") as HTMLElement;
|
||||||
parent.appendChild(this.view)
|
parent.appendChild(this.view);
|
||||||
this.view.classList.add("fileentryouter")
|
this.view.classList.add("fileentryouter");
|
||||||
|
|
||||||
const template = require('./fileviewentry.html') as string;
|
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 name = document.getElementById("file_" + fileid + "_name") 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 = () => {
|
||||||
controller.deleteFile(fileinfo.filename);
|
controller.deleteSlot(saveinfo.idx);
|
||||||
}
|
};
|
||||||
|
|
||||||
let downloadBtn = document.getElementById("file_" + fileid + "_download") as HTMLAnchorElement;
|
let downloadBtn = document.getElementById("file_" + fileid + "_download") as HTMLAnchorElement;
|
||||||
downloadBtn.href = public_url + "/file?filename=" + fileinfo.filename
|
downloadBtn.href = public_url + "/get_config?saveidx=" + saveinfo.idx;
|
||||||
downloadBtn.download = fileinfo.filename
|
downloadBtn.download = "config_slot_" + saveinfo.idx + ".json";
|
||||||
|
|
||||||
name.innerText = fileinfo.filename;
|
name.innerText = "Slot " + saveinfo.idx;
|
||||||
size.innerText = fileinfo.size.toString()
|
size.innerText = saveinfo.len + " bytes";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<div class="flexcontainer">
|
<div class="flexcontainer">
|
||||||
<div id="file_${fileid}_name" class="filetitle">Name</div>
|
<div id="file_${fileid}_name" class="filetitle">Slot</div>
|
||||||
</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>
|
||||||
<a id = "file_${fileid}_download" class="filevalue" target="_blank">Download</a>
|
<a id="file_${fileid}_download" class="filevalue" target="_blank">Download</a>
|
||||||
<button id = "file_${fileid}_delete" class="filevalue">Delete</button>
|
<button id="file_${fileid}_delete" class="filevalue">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
SetTime, SSIDList, TankInfo,
|
SetTime, SSIDList, TankInfo,
|
||||||
TestPump,
|
TestPump,
|
||||||
VersionInfo,
|
VersionInfo,
|
||||||
FileList, SolarState, PumpTestResult, Detection, CanPower
|
SaveInfo, SolarState, PumpTestResult, Detection, CanPower
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import {SolarView} from "./solarview";
|
import {SolarView} from "./solarview";
|
||||||
import {toast} from "./toast";
|
import {toast} from "./toast";
|
||||||
@@ -93,65 +93,36 @@ export class Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFileList(): Promise<void> {
|
async updateSaveList(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(PUBLIC_URL + "/files");
|
const response = await fetch(PUBLIC_URL + "/list_saves");
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
const filelist = json as FileList;
|
const saves = json as SaveInfo[];
|
||||||
controller.fileview.setFileList(filelist, PUBLIC_URL);
|
controller.fileview.setSaveList(saves, PUBLIC_URL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFile(file: File, name: string) {
|
deleteSlot(idx: number) {
|
||||||
let current = 0;
|
controller.progressview.addIndeterminate("slot_delete", "Deleting slot " + idx);
|
||||||
let max = 100;
|
|
||||||
controller.progressview.addProgress("file_upload", (current / max) * 100, "Uploading File " + name + "(" + current + "/" + max + ")")
|
|
||||||
const ajax = new XMLHttpRequest();
|
const ajax = new XMLHttpRequest();
|
||||||
ajax.upload.addEventListener("progress", event => {
|
ajax.open("DELETE", PUBLIC_URL + "/delete_save?idx=" + idx);
|
||||||
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.send();
|
ajax.send();
|
||||||
ajax.addEventListener("error", () => {
|
ajax.addEventListener("error", () => {
|
||||||
controller.progressview.removeProgress("file_delete")
|
controller.progressview.removeProgress("slot_delete");
|
||||||
alert("Error delete")
|
alert("Error deleting slot");
|
||||||
controller.updateFileList()
|
controller.updateSaveList();
|
||||||
}, false);
|
}, false);
|
||||||
ajax.addEventListener("abort", () => {
|
ajax.addEventListener("abort", () => {
|
||||||
controller.progressview.removeProgress("file_delete")
|
controller.progressview.removeProgress("slot_delete");
|
||||||
alert("Error upload")
|
alert("Aborted deleting slot");
|
||||||
controller.updateFileList()
|
controller.updateSaveList();
|
||||||
}, false);
|
}, false);
|
||||||
ajax.addEventListener("load", () => {
|
ajax.addEventListener("load", () => {
|
||||||
controller.progressview.removeProgress("file_delete")
|
controller.progressview.removeProgress("slot_delete");
|
||||||
controller.updateFileList()
|
controller.updateSaveList();
|
||||||
}, false);
|
}, false);
|
||||||
controller.updateFileList()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRTCData(): Promise<void> {
|
async updateRTCData(): Promise<void> {
|
||||||
@@ -668,7 +639,7 @@ const tasks = [
|
|||||||
{task: controller.updateSolarData, displayString: "Updating Solar Data"},
|
{task: controller.updateSolarData, displayString: "Updating Solar Data"},
|
||||||
{task: controller.downloadConfig, displayString: "Downloading Configuration"},
|
{task: controller.downloadConfig, displayString: "Downloading Configuration"},
|
||||||
{task: controller.version, displayString: "Fetching Version Information"},
|
{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.getBackupInfo, displayString: "Fetching Backup Information"},
|
||||||
{task: controller.loadLogLocaleConfig, displayString: "Loading Log Localization Config"},
|
{task: controller.loadLogLocaleConfig, displayString: "Loading Log Localization Config"},
|
||||||
{task: controller.loadTankInfo, displayString: "Loading Tank Information"},
|
{task: controller.loadTankInfo, displayString: "Loading Tank Information"},
|
||||||
|
|||||||
Submodule website/themes/blowfish updated: 26d1205439...f9eb1d4e81
Reference in New Issue
Block a user