Introduce watchdog and serialization improvements

- Added watchdog timer for improved system stability and responsiveness.
- Switched save data serialization to Bincode for better efficiency.
- Enhanced compatibility by supporting fallback to older JSON format.
- Improved logging during flash operations for easier debugging.
- Simplified SavegameManager by managing storage directly.
This commit is contained in:
2026-04-12 20:38:52 +02:00
parent 95f7488fa3
commit b26206eb96
7 changed files with 152 additions and 68 deletions

View File

@@ -2,5 +2,6 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" /> <mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/../../../website/themes/blowfish" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -140,7 +140,8 @@ macro_rules! mk_static {
impl Esp<'_> { impl Esp<'_> {
pub fn get_time(&self) -> DateTime<Utc> { pub fn get_time(&self) -> DateTime<Utc> {
DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64).unwrap_or(DateTime::UNIX_EPOCH) DateTime::from_timestamp_micros(self.rtc.current_time_us() as i64)
.unwrap_or(DateTime::UNIX_EPOCH)
} }
pub fn set_time(&mut self, time: DateTime<Utc>) { pub fn set_time(&mut self, time: DateTime<Utc>) {
@@ -517,10 +518,7 @@ impl Esp<'_> {
Ok(*stack) Ok(*stack)
} }
pub fn deep_sleep( pub fn deep_sleep(&mut self, duration_in_ms: u64) -> ! {
&mut self,
duration_in_ms: u64,
) -> ! {
// Mark the current OTA image as valid if we reached here while in pending verify. // Mark the current OTA image as valid if we reached here while in pending verify.
if let Ok(cur) = self.ota.current_ota_state() { if let Ok(cur) = self.ota.current_ota_state() {
if cur == OtaImageState::PendingVerify { if cur == OtaImageState::PendingVerify {
@@ -556,23 +554,24 @@ impl Esp<'_> {
} }
/// Load a config from a specific save slot. /// Load a config from a specific save slot.
pub(crate) async fn load_config_slot( pub(crate) async fn load_config_slot(&mut self, idx: usize) -> FatResult<String> {
&mut self,
idx: usize,
) -> FatResult<String> {
match self.savegame.load_slot(idx)? { match self.savegame.load_slot(idx)? {
None => bail!("Slot {idx} is empty or invalid"), None => bail!("Slot {idx} is empty or invalid"),
Some(data) => { Some(data) => Ok(String::from_utf8_lossy(&data).to_string()),
Ok(String::from_utf8_lossy(&data).to_string())
}
} }
} }
/// Persist a JSON config blob to the next wear-leveling slot. /// Persist a JSON config blob to the next wear-leveling slot.
pub(crate) async fn save_config(&mut self, mut config: Vec<u8>) -> FatResult<()> { /// Retries once on flash error.
pub(crate) async fn save_config(&mut self, config: Vec<u8>) -> FatResult<()> {
let timestamp = self.get_time().to_rfc3339(); let timestamp = self.get_time().to_rfc3339();
self.savegame.save(config.as_mut_slice(), &timestamp)?; match self.savegame.save(config.as_slice(), &timestamp) {
Ok(()) Ok(()) => Ok(()),
Err(e) => {
warn!("First save attempt failed: {e:?}. Retrying...");
self.savegame.save(config.as_slice(), &timestamp)
}
}
} }
/// Delete a specific save slot by erasing it on flash. /// Delete a specific save slot by erasing it on flash.
@@ -608,7 +607,14 @@ impl Esp<'_> {
if to_config_mode { if to_config_mode {
RESTART_TO_CONF = 1; RESTART_TO_CONF = 1;
} }
log(LogMessage::RestartToConfig, RESTART_TO_CONF as u32, 0, "", "").await; log(
LogMessage::RestartToConfig,
RESTART_TO_CONF as u32,
0,
"",
"",
)
.await;
log( log(
LogMessage::LowVoltage, LogMessage::LowVoltage,
LOW_VOLTAGE_DETECTED as u32, LOW_VOLTAGE_DETECTED as u32,
@@ -702,6 +708,7 @@ impl Esp<'_> {
let mqtt_timeout = 15000; let mqtt_timeout = 15000;
let res = async { let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) { while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await; Timer::after(Duration::from_millis(100)).await;
} }
Ok::<(), FatError>(()) Ok::<(), FatError>(())
@@ -720,6 +727,7 @@ impl Esp<'_> {
let res = async { let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) { while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await; Timer::after(Duration::from_millis(100)).await;
} }
Ok::<(), FatError>(()) Ok::<(), FatError>(())

View File

@@ -3,8 +3,8 @@ 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;
pub(crate) mod savegame_manager;
pub(crate) mod rtc; pub(crate) mod rtc;
pub(crate) mod savegame_manager;
mod shared_flash; mod shared_flash;
mod v4_hal; mod v4_hal;
mod water; mod water;
@@ -85,7 +85,7 @@ use esp_alloc as _;
use esp_backtrace as _; use esp_backtrace as _;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_hal::delay::Delay; use esp_hal::delay::Delay;
use esp_hal::i2c::master::{BusTimeout, Config, I2c}; use esp_hal::i2c::master::{BusTimeout, Config, FsmTimeout, I2c, SoftwareTimeout};
use esp_hal::interrupt::software::SoftwareInterruptControl; use esp_hal::interrupt::software::SoftwareInterruptControl;
use esp_hal::pcnt::unit::Unit; use esp_hal::pcnt::unit::Unit;
use esp_hal::pcnt::Pcnt; use esp_hal::pcnt::Pcnt;
@@ -93,7 +93,7 @@ use esp_hal::rng::Rng;
use esp_hal::rtc_cntl::{Rtc, SocResetReason}; use esp_hal::rtc_cntl::{Rtc, SocResetReason};
use esp_hal::system::reset_reason; use esp_hal::system::reset_reason;
use esp_hal::time::Rate; use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup; use esp_hal::timer::timg::{MwdtStage, TimerGroup, Wdt};
use esp_hal::uart::Uart; use esp_hal::uart::Uart;
use esp_hal::Blocking; use esp_hal::Blocking;
use esp_radio::{init, Controller}; use esp_radio::{init, Controller};
@@ -108,6 +108,13 @@ pub const PLANT_COUNT: usize = 8;
pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false); pub static PROGRESS_ACTIVE: AtomicBool = AtomicBool::new(false);
pub static WATCHDOG: OnceLock<
embassy_sync::blocking_mutex::Mutex<
CriticalSectionRawMutex,
RefCell<Wdt<esp_hal::peripherals::TIMG0>>,
>,
> = OnceLock::new();
const TANK_MULTI_SAMPLE: usize = 11; const TANK_MULTI_SAMPLE: usize = 11;
pub static I2C_DRIVER: OnceLock< pub static I2C_DRIVER: OnceLock<
embassy_sync::blocking_mutex::Mutex<CriticalSectionRawMutex, RefCell<I2c<Blocking>>>, embassy_sync::blocking_mutex::Mutex<CriticalSectionRawMutex, RefCell<I2c<Blocking>>>,
@@ -174,6 +181,9 @@ pub trait BoardInteraction<'a> {
// Indicate progress is active to suppress default wait_infinity blinking // Indicate progress is active to suppress default wait_infinity blinking
PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed); PROGRESS_ACTIVE.store(true, core::sync::atomic::Ordering::Relaxed);
// Feed watchdog during long-running webserver operations
PlantHal::feed_watchdog();
let current = counter % PLANT_COUNT as u32; let current = counter % PLANT_COUNT as u32;
for led in 0..PLANT_COUNT { for led in 0..PLANT_COUNT {
if let Err(err) = self.fault(led, current == led as u32).await { if let Err(err) = self.fault(led, current == led as u32).await {
@@ -251,6 +261,16 @@ impl PlantHal {
let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT); let sw_int = SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_int.software_interrupt0); esp_rtos::start(timg0.timer0, sw_int.software_interrupt0);
// Initialize and enable the watchdog with 30 second timeout
let mut wdt = timg0.wdt;
wdt.set_timeout(MwdtStage::Stage0, esp_hal::time::Duration::from_secs(30));
wdt.enable();
WATCHDOG
.init(embassy_sync::blocking_mutex::Mutex::new(RefCell::new(wdt)))
.map_err(|_| FatError::String {
error: "Watchdog already initialized".to_string(),
})?;
let boot_button = Input::new( let boot_button = Input::new(
peripherals.GPIO9, peripherals.GPIO9,
InputConfig::default().with_pull(Pull::None), InputConfig::default().with_pull(Pull::None),
@@ -380,7 +400,11 @@ impl PlantHal {
); );
let savegame = SavegameManager::new(data); let savegame = SavegameManager::new(data);
info!("Savegame storage initialized ({} slots × {} KB)", savegame_manager::SAVEGAME_SLOT_COUNT, savegame_manager::SAVEGAME_SLOT_SIZE / 1024); info!(
"Savegame storage initialized ({} slots × {} KB)",
savegame_manager::SAVEGAME_SLOT_COUNT,
savegame_manager::SAVEGAME_SLOT_SIZE / 1024
);
let uart0 = let uart0 =
Uart::new(peripherals.UART0, UartConfig::default()).map_err(|_| FatError::String { Uart::new(peripherals.UART0, UartConfig::default()).map_err(|_| FatError::String {
@@ -458,11 +482,16 @@ impl PlantHal {
let sda = peripherals.GPIO20; let sda = peripherals.GPIO20;
let scl = peripherals.GPIO19; let scl = peripherals.GPIO19;
// Configure I2C with 1-second timeout
// At 100 Hz I2C clock, one bus cycle = 10ms
// For 1 second timeout: 100 bus cycles
let i2c = I2c::new( let i2c = I2c::new(
peripherals.I2C0, peripherals.I2C0,
Config::default() Config::default()
.with_frequency(Rate::from_hz(100)) //.with_frequency(Rate::from_hz(100))
.with_timeout(BusTimeout::Maximum), //1s at 100khz
.with_timeout(BusTimeout::BusCycles(100_000))
.with_scl_main_st_timeout(FsmTimeout::new(21)?),
)? )?
.with_scl(scl) .with_scl(scl)
.with_sda(sda); .with_sda(sda);
@@ -563,6 +592,15 @@ impl PlantHal {
Ok(Mutex::new(hal)) Ok(Mutex::new(hal))
} }
/// Feed the watchdog timer to prevent system reset
pub fn feed_watchdog() {
if let Some(wdt_mutex) = WATCHDOG.try_get() {
wdt_mutex.lock(|cell| {
cell.borrow_mut().feed();
});
}
}
} }
fn ota_state( fn ota_state(

View File

@@ -1,7 +1,9 @@
use alloc::vec::Vec; use alloc::vec::Vec;
use bincode::{Decode, Encode};
use embedded_savegame::storage::{Flash, Storage}; use embedded_savegame::storage::{Flash, Storage};
use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; use embedded_storage::nor_flash::{NorFlash, ReadNorFlash};
use esp_bootloader_esp_idf::partitions::{Error as PartitionError, FlashRegion}; use esp_bootloader_esp_idf::partitions::{Error as PartitionError, Error, FlashRegion};
use log::{error, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::fat_error::{FatError, FatResult}; use crate::fat_error::{FatError, FatResult};
@@ -24,7 +26,7 @@ pub struct SaveInfo {
} }
/// Wrapper that includes both the config data and metadata like creation timestamp. /// Wrapper that includes both the config data and metadata like creation timestamp.
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug, Encode, Decode)]
struct SaveWrapper { struct SaveWrapper {
/// UTC timestamp in RFC3339 format /// UTC timestamp in RFC3339 format
created_at: alloc::string::String, created_at: alloc::string::String,
@@ -71,7 +73,21 @@ impl Flash for SavegameFlashAdapter<'_> {
// Align end address up to erase boundary // Align end address up to erase boundary
let end = addr + SAVEGAME_SLOT_SIZE as u32; let end = addr + SAVEGAME_SLOT_SIZE as u32;
let aligned_end = ((end + ERASE_SIZE - 1) / ERASE_SIZE) * ERASE_SIZE; let aligned_end = ((end + ERASE_SIZE - 1) / ERASE_SIZE) * ERASE_SIZE;
NorFlash::erase(self.region, aligned_start, aligned_end).map_err(SavegameFlashError)
if aligned_start != addr || aligned_end != end {
log::warn!("Flash erase address not aligned: addr=0x{:x}, slot_size=0x{:x}. Aligned to 0x{:x}-0x{:x}", addr, SAVEGAME_SLOT_SIZE, aligned_start, aligned_end);
}
match NorFlash::erase(self.region, aligned_start, aligned_end) {
Ok(_) => Ok(()),
Err(err) => {
error!(
"Flash erase failed: {:?}. 0x{:x}-0x{:x}",
err, aligned_start, aligned_end
);
Err(SavegameFlashError(err))
}
}
} }
} }
@@ -88,21 +104,14 @@ impl From<SavegameFlashError> for FatError {
/// High-level save-game manager that stores JSON config blobs on the storage /// High-level save-game manager that stores JSON config blobs on the storage
/// partition using [`embedded_savegame`] for wear leveling and power-fail safety. /// partition using [`embedded_savegame`] for wear leveling and power-fail safety.
pub struct SavegameManager { pub struct SavegameManager {
region: &'static mut FlashRegion<'static, MutexFlashStorage>, storage: Storage<SavegameFlashAdapter<'static>, SAVEGAME_SLOT_SIZE, SAVEGAME_SLOT_COUNT>,
} }
impl SavegameManager { impl SavegameManager {
pub fn new(region: &'static mut FlashRegion<'static, MutexFlashStorage>) -> Self { pub fn new(region: &'static mut FlashRegion<'static, MutexFlashStorage>) -> Self {
Self { region } Self {
} storage: Storage::new(SavegameFlashAdapter { 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 with a UTC timestamp. /// Persist `data` (JSON bytes) to the next available slot with a UTC timestamp.
@@ -110,35 +119,42 @@ impl SavegameManager {
/// `scan()` advances the internal wear-leveling pointer to the latest valid /// `scan()` advances the internal wear-leveling pointer to the latest valid
/// slot before `append()` writes to the next free one. /// slot before `append()` writes to the next free one.
/// Both operations are performed atomically on the same Storage instance. /// Both operations are performed atomically on the same Storage instance.
pub fn save(&mut self, data: &mut [u8], timestamp: &str) -> FatResult<()> { pub fn save(&mut self, data: &[u8], timestamp: &str) -> FatResult<()> {
let wrapper = SaveWrapper { let wrapper = SaveWrapper {
created_at: alloc::string::String::from(timestamp), created_at: alloc::string::String::from(timestamp),
data: data.to_vec(), data: data.to_vec(),
}; };
let mut serialized = serde_json::to_vec(&wrapper)?; let mut serialized = bincode::encode_to_vec(&wrapper, bincode::config::standard())?;
let mut st = self.storage(); info!("Serialized config with size {}", serialized.len());
let _slot = st.scan()?; (&mut self.storage).append(&mut serialized)?;
st.append(&mut serialized)?;
Ok(()) Ok(())
} }
/// Load the most recently saved data. Returns `None` if no valid save exists. /// Load the most recently saved data. Returns `None` if no valid save exists.
/// Unwraps the SaveWrapper and returns only the config data. /// Unwraps the SaveWrapper and returns only the config data.
pub fn load_latest(&mut self) -> FatResult<Option<Vec<u8>>> { pub fn load_latest(&mut self) -> FatResult<Option<Vec<u8>>> {
let mut st = self.storage(); let slot = (&mut self.storage).scan()?;
let slot = st.scan()?;
match slot { match slot {
None => Ok(None), None => Ok(None),
Some(slot) => { Some(slot) => {
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
match st.read(slot.idx, &mut buf)? { match (&mut self.storage).read(slot.idx, &mut buf)? {
None => Ok(None), None => Ok(None),
Some(data) => { Some(data) => {
// Try to deserialize as SaveWrapper (new format) // Try to deserialize as SaveWrapper (new Bincode format)
match serde_json::from_slice::<SaveWrapper>(data) { match bincode::decode_from_slice::<SaveWrapper, _>(
Ok(wrapper) => Ok(Some(wrapper.data)), data,
// Fallback to raw data for backwards compatibility bincode::config::standard(),
Err(_) => Ok(Some(data.to_vec())), ) {
Ok((wrapper, _)) => Ok(Some(wrapper.data)),
Err(_) => {
// Fallback to JSON SaveWrapper (intermediate format)
match serde_json::from_slice::<SaveWrapper>(data) {
Ok(wrapper) => Ok(Some(wrapper.data)),
// Fallback to raw data for backwards compatibility
Err(_) => Ok(Some(data.to_vec())),
}
}
} }
} }
} }
@@ -150,16 +166,24 @@ impl SavegameManager {
/// empty or contains an invalid checksum. /// empty or contains an invalid checksum.
/// Unwraps the SaveWrapper and returns only the config data. /// Unwraps the SaveWrapper and returns only the config data.
pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> { pub fn load_slot(&mut self, idx: usize) -> FatResult<Option<Vec<u8>>> {
let mut st = self.storage();
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
match st.read(idx, &mut buf)? { match (&mut self.storage).read(idx, &mut buf)? {
None => Ok(None), None => Ok(None),
Some(data) => { Some(data) => {
// Try to deserialize as SaveWrapper (new format) // Try to deserialize as SaveWrapper (new Bincode format)
match serde_json::from_slice::<SaveWrapper>(data) { match bincode::decode_from_slice::<SaveWrapper, _>(
Ok(wrapper) => Ok(Some(wrapper.data)), data,
// Fallback to raw data for backwards compatibility bincode::config::standard(),
Err(_) => Ok(Some(data.to_vec())), ) {
Ok((wrapper, _)) => Ok(Some(wrapper.data)),
Err(_) => {
// Fallback to JSON SaveWrapper (intermediate format)
match serde_json::from_slice::<SaveWrapper>(data) {
Ok(wrapper) => Ok(Some(wrapper.data)),
// Fallback to raw data for backwards compatibility
Err(_) => Ok(Some(data.to_vec())),
}
}
} }
} }
} }
@@ -167,8 +191,7 @@ impl SavegameManager {
/// Erase a specific slot by index, effectively deleting it. /// Erase a specific slot by index, effectively deleting it.
pub fn delete_slot(&mut self, idx: usize) -> FatResult<()> { pub fn delete_slot(&mut self, idx: usize) -> FatResult<()> {
let mut st = self.storage(); (&mut self.storage).erase(idx).map_err(Into::into)
st.erase(idx).map_err(Into::into)
} }
/// Iterate all slots and return metadata for every slot that contains a /// Iterate all slots and return metadata for every slot that contains a
@@ -176,15 +199,23 @@ impl SavegameManager {
/// Extracts timestamps from SaveWrapper if available. /// Extracts timestamps from SaveWrapper if available.
pub fn list_saves(&mut self) -> FatResult<Vec<SaveInfo>> { pub fn list_saves(&mut self) -> FatResult<Vec<SaveInfo>> {
let mut saves = Vec::new(); let mut saves = Vec::new();
let mut st = self.storage();
let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE]; let mut buf = alloc::vec![0u8; SAVEGAME_SLOT_SIZE];
for idx in 0..SAVEGAME_SLOT_COUNT { for idx in 0..SAVEGAME_SLOT_COUNT {
if let Some(data) = st.read(idx, &mut buf)? { if let Some(data) = (&mut self.storage).read(idx, &mut buf)? {
// Try to deserialize as SaveWrapper to extract timestamp // Try to deserialize as SaveWrapper (new Bincode format)
let (len, created_at) = match serde_json::from_slice::<SaveWrapper>(data) { let (len, created_at) = match bincode::decode_from_slice::<SaveWrapper, _>(
Ok(wrapper) => (wrapper.data.len() as u32, Some(wrapper.created_at)), data,
// Old format without timestamp bincode::config::standard(),
Err(_) => (data.len() as u32, None), ) {
Ok((wrapper, _)) => (wrapper.data.len() as u32, Some(wrapper.created_at)),
Err(_) => {
// Fallback to JSON SaveWrapper (intermediate format)
match serde_json::from_slice::<SaveWrapper>(data) {
Ok(wrapper) => (wrapper.data.len() as u32, Some(wrapper.created_at)),
// Old format without timestamp
Err(_) => (data.len() as u32, None),
}
}
}; };
saves.push(SaveInfo { saves.push(SaveInfo {

View File

@@ -749,6 +749,7 @@ pub async fn do_secure_pump(
} }
None => Duration::from_millis(1), None => Duration::from_millis(1),
}; };
hal::PlantHal::feed_watchdog();
Timer::after(sleep_time).await; Timer::after(sleep_time).await;
pump_time_ms += 50; pump_time_ms += 50;
} }
@@ -1119,6 +1120,8 @@ async fn wait_infinity(
Timer::after_millis(delay).await; Timer::after_millis(delay).await;
hal::PlantHal::feed_watchdog();
if wait_type == WaitType::MqttConfig && !MQTT_STAY_ALIVE.load(Ordering::Relaxed) { if wait_type == WaitType::MqttConfig && !MQTT_STAY_ALIVE.load(Ordering::Relaxed) {
reboot_now.store(true, Ordering::Relaxed); reboot_now.store(true, Ordering::Relaxed);
} }

View File

@@ -240,7 +240,10 @@ export class Controller {
.then(_ => { .then(_ => {
controller.progressview.removeProgress("set_config"); controller.progressview.removeProgress("set_config");
setTimeout(() => { setTimeout(() => {
controller.downloadConfig() controller.downloadConfig().then(r => {
controller.updateSaveList().then(r => {
});
});
}, 250) }, 250)
}) })
} }

View File

@@ -28,11 +28,11 @@ export class SubmitView {
controller.uploadConfig(this.json.textContent as string, (status: string) => { controller.uploadConfig(this.json.textContent as string, (status: string) => {
if (status != "OK") { if (status != "OK") {
// Show error toast (click to dismiss only) // Show error toast (click to dismiss only)
const { toast } = require('./toast'); const {toast} = require('./toast');
toast.error(status); toast.error(status);
} else { } else {
// Show info toast (auto hides after 5s, or click to dismiss sooner) // Show info toast (auto hides after 5s, or click to dismiss sooner)
const { toast } = require('./toast'); const {toast} = require('./toast');
toast.info('Config uploaded successfully'); toast.info('Config uploaded successfully');
} }
this.submit_status.innerHTML = status; this.submit_status.innerHTML = status;