stuff
This commit is contained in:
		
							
								
								
									
										11
									
								
								bootloader/CMakeLists.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								bootloader/CMakeLists.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| cmake_minimum_required(VERSION 3.16) | ||||
|  | ||||
| # Minimal ESP-IDF project to build only the bootloader | ||||
| # You must have ESP-IDF installed and IDF_PATH exported. | ||||
|  | ||||
| # Pin the target to ESP32-C6 to ensure correct bootloader build | ||||
| # (must be set before including project.cmake) | ||||
| set(IDF_TARGET "esp32c6") | ||||
|  | ||||
| include($ENV{IDF_PATH}/tools/cmake/project.cmake) | ||||
| project(custom_bootloader) | ||||
							
								
								
									
										43
									
								
								bootloader/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								bootloader/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| Custom ESP-IDF Bootloader (Rollback Enabled) | ||||
|  | ||||
| This minimal project builds a custom ESP-IDF bootloader with rollback support enabled. | ||||
| You can flash it later alongside a Rust firmware using `espflash`. | ||||
|  | ||||
| What this provides | ||||
| - A minimal ESP-IDF project (CMake) that can build just the bootloader. | ||||
| - Rollback support enabled via sdkconfig.defaults (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y). | ||||
| - A sample OTA partition table (partitions.csv) suitable for OTA and rollback (otadata + two OTA slots). | ||||
| - A convenience script to build the bootloader for the desired target. | ||||
|  | ||||
| Requirements | ||||
| - ESP-IDF installed and set up (IDF_PATH exported, Python env activated). | ||||
| - A selected target (esp32, esp32s3, esp32c3, etc.). | ||||
|  | ||||
| Build | ||||
| 1) Ensure ESP-IDF is set up: | ||||
|    source "$IDF_PATH/export.sh" | ||||
|  | ||||
| 2) Pick a target (examples): | ||||
|    idf.py set-target esp32 | ||||
|    # or use the script: | ||||
|    ./build_bootloader.sh esp32 | ||||
|  | ||||
| 3) Build only the bootloader: | ||||
|    idf.py bootloader | ||||
|    # or using the script (which also supports setting target): | ||||
|    ./build_bootloader.sh esp32 | ||||
|  | ||||
| Artifacts | ||||
| - build/bootloader/bootloader.bin | ||||
|  | ||||
| Using with espflash (Rust) | ||||
| - For a no_std Rust firmware, you can pass this custom bootloader to espflash: | ||||
|   espflash flash --bootloader build/bootloader/bootloader.bin \ | ||||
|                  --partition-table partitions.csv \ | ||||
|                  <your-app-binary-or-elf> | ||||
|  | ||||
| Notes | ||||
| - Rollback logic requires an OTA layout (otadata + at least two OTA app partitions). The provided partitions.csv is a starting point; adjust sizes/offsets to match your needs. | ||||
| - This project doesn’t build an application; it exists solely to produce a bootloader with the right configuration. | ||||
| - If you need different log verbosity or features, run `idf.py menuconfig` and then diff/port the changes back into sdkconfig.defaults. | ||||
| - Targets supported depend on your ESP-IDF version. Use `idf.py set-target <chip>` or `./build_bootloader.sh <chip>`. | ||||
							
								
								
									
										41
									
								
								bootloader/build_bootloader.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										41
									
								
								bootloader/build_bootloader.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| #!/usr/bin/env bash | ||||
| set -euo pipefail | ||||
|  | ||||
| # Build script for custom ESP-IDF bootloader with rollback enabled. | ||||
| # Requirements: | ||||
| #   - ESP-IDF installed | ||||
| #   - IDF_PATH exported | ||||
| #   - Python env prepared (the usual ESP-IDF setup) | ||||
| # Usage: | ||||
| #   ./build_bootloader.sh [esp32|esp32s3|esp32c3|esp32s2|esp32c2|esp32c6|esp32h2] | ||||
| #   If target is omitted, the last configured target will be used. | ||||
|  | ||||
| TARGET=${1:-} | ||||
|  | ||||
| if [[ -z "${IDF_PATH:-}" ]]; then | ||||
|   echo "ERROR: IDF_PATH is not set. Please install ESP-IDF and export the environment (source export.sh)." >&2 | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # shellcheck source=/dev/null | ||||
| source "$IDF_PATH/export.sh" | ||||
|  | ||||
| if [[ -n "$TARGET" ]]; then | ||||
|   idf.py set-target "$TARGET" | ||||
| fi | ||||
|  | ||||
| # Ensure sdkconfig.defaults is considered (ESP-IDF does this automatically). | ||||
| # Build only the bootloader. | ||||
| idf.py bootloader | ||||
|  | ||||
| echo | ||||
| BOOTLOADER_BIN="build/bootloader/bootloader.bin" | ||||
| if [[ -f "$BOOTLOADER_BIN" ]]; then | ||||
|   echo "Bootloader built: $BOOTLOADER_BIN" | ||||
|   echo "You can use this with espflash via:" | ||||
|   echo "  espflash flash --bootloader $BOOTLOADER_BIN [--partition-table partitions.csv] <your-app-binary>" | ||||
| else | ||||
|   echo "ERROR: Bootloader binary not found. Check build logs above." >&2 | ||||
|   exit 2 | ||||
| fi | ||||
| cp build/bootloader/bootloader.bin ../rust/bootloader.bin | ||||
							
								
								
									
										1
									
								
								bootloader/main/CMakeLists.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								bootloader/main/CMakeLists.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| idf_component_register(SRCS "dummy.c" INCLUDE_DIRS ".") | ||||
							
								
								
									
										4
									
								
								bootloader/main/dummy.c
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								bootloader/main/dummy.c
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| // This file intentionally left almost empty. | ||||
| // ESP-IDF expects at least one component; the bootloader build does not use this. | ||||
|  | ||||
| void __unused_dummy_symbol(void) {} | ||||
							
								
								
									
										6
									
								
								bootloader/partitions.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								bootloader/partitions.csv
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| nvs,      data, nvs,     ,        16k, | ||||
| otadata,  data, ota,     ,        8k, | ||||
| phy_init, data, phy,     ,        4k, | ||||
| ota_0,    app,  ota_0,   ,        3968k, | ||||
| ota_1,    app,  ota_1,   ,        3968k, | ||||
| storage,  data, littlefs,,        8M, | ||||
| 
 | 
							
								
								
									
										2385
									
								
								bootloader/sdkconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2385
									
								
								bootloader/sdkconfig
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										17
									
								
								bootloader/sdkconfig.defaults
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								bootloader/sdkconfig.defaults
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # Target can be set with: idf.py set-target esp32|esp32s3|esp32c3|... | ||||
| # If not set via idf.py, ESP-IDF may default to a target; it's recommended to set it explicitly. | ||||
|  | ||||
| # Explicitly pin target to ESP32-C6 | ||||
| CONFIG_IDF_TARGET="esp32c6" | ||||
| CONFIG_IDF_TARGET_ESP32C6=y | ||||
| CONFIG_IDF_TARGET_ARCH_RISCV=y | ||||
|  | ||||
| # Bootloader configuration | ||||
| CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y | ||||
| CONFIG_BOOTLOADER_LOG_LEVEL_INFO=y | ||||
| # Slightly faster boot by skipping GPIO checks unless you need that feature | ||||
| CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP=y | ||||
|  | ||||
| # Partition table config is not required to build bootloader, but shown for clarity when you build full app later | ||||
| # CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" | ||||
| # CONFIG_PARTITION_TABLE_FILENAME="partitions.csv" | ||||
| @@ -10,7 +10,7 @@ rustflags = [ | ||||
| target = "riscv32imac-unknown-none-elf" | ||||
|  | ||||
| [target.riscv32imac-unknown-none-elf] | ||||
| runner = "espflash flash --monitor --chip esp32c6 --baud 921600 --partition-table partitions.csv" | ||||
| #runner = "espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv" | ||||
| #runner = "espflash flash --monitor --baud 921600 --partition-table partitions.csv -b no-reset" # Select this runner in case of usb ttl | ||||
| #runner = "espflash flash --monitor" | ||||
| #runner = "cargo runner" | ||||
|   | ||||
| @@ -13,19 +13,6 @@ test = false | ||||
| bench = false | ||||
| doc = false | ||||
|  | ||||
| [package.metadata.cargo_runner] | ||||
| # The string `$TARGET_FILE` will be replaced with the path from cargo. | ||||
| command = [ | ||||
|     "cargo", | ||||
|     "espflash", | ||||
|     "save-image", | ||||
|     "--chip", | ||||
|     "esp32c6", | ||||
|     "image.bin", | ||||
|     "--partition-table", | ||||
|     "partitions.csv" | ||||
| ] | ||||
|  | ||||
| #this strips the bootloader, we need that tho | ||||
| #strip = true | ||||
|  | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								rust/bootloader.bin
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								rust/bootloader.bin
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -50,7 +50,7 @@ fn linker_be_nice() { | ||||
| } | ||||
|  | ||||
| fn main() { | ||||
|     webpack(); | ||||
|     //webpack(); | ||||
|     linker_be_nice(); | ||||
|     let _ = EmitBuilder::builder().all_git().all_build().emit(); | ||||
| } | ||||
|   | ||||
							
								
								
									
										11
									
								
								rust/flash.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										11
									
								
								rust/flash.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| rm ./src/webserver/index.html.gz | ||||
| rm ./src/webserver/bundle.js.gz | ||||
| set -e | ||||
| cd ./src_webpack/ | ||||
| npx webpack build | ||||
| cp index.html.gz ../src/webserver/index.html.gz | ||||
| cp bundle.js.gz ../src/webserver/bundle.js.gz | ||||
| cd ../ | ||||
|  | ||||
| cargo build | ||||
| espflash flash --monitor --bootloader bootloader.bin --chip esp32c6 --baud 921600 --partition-table partitions.csv  target/riscv32imac-unknown-none-elf/release/plant-ctrl2 | ||||
							
								
								
									
										13
									
								
								rust/image_build.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										13
									
								
								rust/image_build.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| rm image.bin | ||||
| rm ./src/webserver/index.html.gz | ||||
| rm ./src/webserver/bundle.js.gz | ||||
| set -e | ||||
| cd ./src_webpack/ | ||||
| npx webpack build | ||||
| cp index.html.gz ../src/webserver/index.html.gz | ||||
| cp bundle.js.gz ../src/webserver/bundle.js.gz | ||||
| cd ../ | ||||
|  | ||||
| set -e | ||||
| cargo build --release | ||||
| espflash save-image --bootloader bootloader.bin --partition-table partitions.csv --chip esp32c6 target/riscv32imac-unknown-none-elf/release/plant-ctrl2 image.bin | ||||
| @@ -6,6 +6,7 @@ use core::str::Utf8Error; | ||||
| use embassy_embedded_hal::shared_bus::I2cDeviceError; | ||||
| use embassy_executor::SpawnError; | ||||
| use embassy_sync::mutex::TryLockError; | ||||
| use embedded_storage::nor_flash::NorFlashErrorKind; | ||||
| use esp_hal::i2c::master::ConfigError; | ||||
| use esp_hal::pcnt::unit::{InvalidHighLimit, InvalidLowLimit}; | ||||
| use esp_hal::twai::EspTwaiError; | ||||
| @@ -297,3 +298,11 @@ impl From<nb::Error<EspTwaiError>> for FatError { | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl From<NorFlashErrorKind> for FatError{ | ||||
|     fn from(value: NorFlashErrorKind) -> Self { | ||||
|         FatError::String { | ||||
|             error: value.to_string() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| use crate::bail; | ||||
| use crate::{bail}; | ||||
| use crate::config::{NetworkConfig, PlantControllerConfig}; | ||||
| use crate::hal::{PLANT_COUNT, TIME_ACCESS}; | ||||
| use crate::hal::{get_next_slot, PLANT_COUNT, TIME_ACCESS}; | ||||
| use crate::log::{LogMessage, LOG_ACCESS}; | ||||
| use chrono::{DateTime, Utc}; | ||||
| use serde::Serialize; | ||||
| @@ -20,7 +20,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::mutex::{Mutex, MutexGuard}; | ||||
| use embassy_sync::once_lock::OnceLock; | ||||
| use embassy_time::{Duration, Timer}; | ||||
| use embedded_storage::nor_flash::{NorFlash, ReadNorFlash}; | ||||
| use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash}; | ||||
| use esp_bootloader_esp_idf::ota::{Ota, OtaImageState}; | ||||
| use esp_bootloader_esp_idf::ota::OtaImageState::Valid; | ||||
| use esp_bootloader_esp_idf::partitions::FlashRegion; | ||||
| @@ -30,7 +30,6 @@ use esp_hal::rtc_cntl::{ | ||||
|     sleep::{TimerWakeupSource, WakeupLevel}, | ||||
|     Rtc, | ||||
| }; | ||||
| use esp_hal::sha::Digest; | ||||
| use esp_hal::system::software_reset; | ||||
| use esp_println::println; | ||||
| use esp_storage::FlashStorage; | ||||
| @@ -214,10 +213,10 @@ impl Esp<'_> { | ||||
|         Ok((buf, read)) | ||||
|     } | ||||
|  | ||||
|     pub(crate) fn get_ota_slot(&mut self) -> String { | ||||
|         match self.ota.current_slot() { | ||||
|     pub(crate) fn get_current_ota_slot(&mut self) -> String { | ||||
|         match get_next_slot(&mut self.ota) { | ||||
|             Ok(slot) => { | ||||
|                 format!("{:?}", slot) | ||||
|                 format!("{:?}", slot.next()) | ||||
|             } | ||||
|             Err(err) => { | ||||
|                 format!("{:?}", err) | ||||
| @@ -241,31 +240,77 @@ impl Esp<'_> { | ||||
|         offset: u32, | ||||
|         buf: &[u8], | ||||
|     ) -> Result<(), FatError> { | ||||
|         if self.ota.current_ota_state() == Ok(OtaImageState::Invalid) { | ||||
|             bail!("Invalid OTA state, refusing ota write") | ||||
|         } | ||||
|         if self.ota.current_ota_state() == Ok(OtaImageState::Undefined) { | ||||
|             bail!("Invalid OTA state, refusing ota write") | ||||
|         } | ||||
|  | ||||
|         let mut read_back = [0_u8; 1024]; | ||||
|         let useful = &mut read_back[..buf.len()]; | ||||
|         //change to nor flash, align writes! | ||||
|         self.ota_next.write(offset, buf)?; | ||||
|         self.ota_next.read(offset, useful)?; | ||||
|         if buf != useful { | ||||
|             info!("Expected {:?} but got {:?}", buf, useful); | ||||
|             bail!("Flash error, read back does not match write buffer at offset {:x}", offset) | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn finalize_ota( | ||||
|         pub(crate) async fn ota_erase(&mut self, block_start: u32) -> FatResult<()> { | ||||
|             // Ensure 4K block size and alignment | ||||
|             if self.ota_next.capacity() % 4096 != 0 { | ||||
|                 bail!("Partition size is not a multiple of 4096") | ||||
|             } | ||||
|             if block_start % 4096 != 0 { | ||||
|                 bail!("ota_erase called with unaligned block_start: {:x}", block_start) | ||||
|             } | ||||
|             let capacity = self.ota_next.capacity() as u32; | ||||
|             if block_start >= capacity { | ||||
|                 bail!("ota_erase block_start out of range: {:x}", block_start) | ||||
|             } | ||||
|             let end = core::cmp::min(block_start + 4096, capacity); | ||||
|             // Check current erase state (will error if not erased); we ignore the result and erase anyway | ||||
|             let _ = check_erase(self.ota_next, block_start, end); | ||||
|             info!("erasing block {:x}-{:x}", block_start, end); | ||||
|             self.ota_next.erase(block_start, end)?; | ||||
|             Ok(()) | ||||
|         } | ||||
|  | ||||
|         pub(crate) async fn finalize_ota( | ||||
|         &mut self, | ||||
|     ) -> Result<(), FatError> { | ||||
|         if self.ota.current_ota_state() == Ok(OtaImageState::Invalid) { | ||||
|             bail!("Invalid OTA state, refusing ota write") | ||||
|         } | ||||
|         if self.ota.current_ota_state() == Ok(OtaImageState::Undefined) { | ||||
|             bail!("Invalid OTA state, refusing ota write") | ||||
|         } | ||||
|  | ||||
|  | ||||
|         let current_state = self.ota.current_ota_state()?; | ||||
|         let current_slot = self.ota.current_slot()?; | ||||
|             info!("current state {:?}", current_state); | ||||
|         let next_slot = get_next_slot(&mut self.ota)?; | ||||
|             info!("current slot {:?}", next_slot.next()); | ||||
|         if current_state == OtaImageState::PendingVerify { | ||||
|             info!("verifying ota image from pending"); | ||||
|             self.ota.set_current_ota_state(Valid)?; | ||||
|         } | ||||
|         self.ota.set_current_slot(current_slot.next())?; | ||||
|  | ||||
|         self.ota.set_current_slot(next_slot)?; | ||||
|             info!("switched slot"); | ||||
|         self.ota.set_current_ota_state(OtaImageState::New)?; | ||||
|             info!("switched state for new partition"); | ||||
|         let state_new = self.ota.current_ota_state()?; | ||||
|         info!("state on new partition now {:?}", state_new); | ||||
|         //determine nextslot crc | ||||
|  | ||||
|         self.set_restart_to_conf(true); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     // let current = ota.current_slot()?; | ||||
|     // println!( | ||||
|     //     "current image state {:?} (only relevant if the bootloader was built with auto-rollback support)", | ||||
|     //     ota.current_ota_state() | ||||
|     // ); | ||||
|     // println!("current {:?} - next {:?}", current, current.next()); | ||||
|     // let ota_state = ota.current_ota_state()?; | ||||
|  | ||||
|     pub(crate) fn mode_override_pressed(&mut self) -> bool { | ||||
|         self.boot_button.is_low() | ||||
|     } | ||||
| @@ -323,11 +368,6 @@ impl Esp<'_> { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub async fn flash_ota(&mut self) -> FatResult<()> { | ||||
|         let capacity = self.ota_next.capacity(); | ||||
|  | ||||
|         bail!("not implemented") | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn wifi_scan(&mut self) -> FatResult<Vec<AccessPointInfo>> { | ||||
|         info!("start wifi scan"); | ||||
| @@ -616,7 +656,11 @@ impl Esp<'_> { | ||||
|     } | ||||
|  | ||||
|     pub(crate) async fn load_config(&mut self) -> FatResult<PlantControllerConfig> { | ||||
|         let cfg = PathBuf::try_from(CONFIG_FILE).unwrap(); | ||||
|         let cfg = PathBuf::try_from(CONFIG_FILE)?; | ||||
|         let config_exist = self.fs.lock().await.exists(&cfg); | ||||
|         if !config_exist { | ||||
|             bail!("No config file stored") | ||||
|         } | ||||
|         let data = self.fs.lock().await.read::<4096>(&cfg)?; | ||||
|         let config: PlantControllerConfig = serde_json::from_slice(&data)?; | ||||
|         return Ok(config); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| use embedded_storage::{ReadStorage, Storage}; | ||||
| use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash}; | ||||
| use esp_bootloader_esp_idf::partitions::FlashRegion; | ||||
| use esp_storage::FlashStorage; | ||||
| use littlefs2::consts::U512 as lfsCache; | ||||
| use littlefs2::consts::U4096 as lfsCache; | ||||
| use littlefs2::consts::U512 as lfsLookahead; | ||||
| use littlefs2::driver::Storage as lfs2Storage; | ||||
| use littlefs2::io::Error as lfs2Error; | ||||
| @@ -13,18 +13,24 @@ pub struct LittleFs2Filesystem { | ||||
| } | ||||
|  | ||||
| impl lfs2Storage for LittleFs2Filesystem { | ||||
|     const READ_SIZE: usize = 256; | ||||
|     const WRITE_SIZE: usize = 512; | ||||
|     const BLOCK_SIZE: usize = 512; //usually optimal for flash access | ||||
|     const BLOCK_COUNT: usize = 8 * 1024 * 1024 / 512; //8mb in 32kb blocks | ||||
|     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; | ||||
|         assert_eq!(off % read_size, 0); | ||||
|         assert_eq!(buf.len() % read_size, 0); | ||||
|         if off % read_size != 0 { | ||||
|             error!("Littlefs2Filesystem read error: offset not aligned to read size offset: {} read_size: {}", off, 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) => { | ||||
| @@ -36,8 +42,14 @@ impl lfs2Storage for LittleFs2Filesystem { | ||||
|  | ||||
|     fn write(&mut self, off: usize, data: &[u8]) -> lfs2Result<usize> { | ||||
|         let write_size: usize = Self::WRITE_SIZE; | ||||
|         assert_eq!(off % write_size, 0); | ||||
|         assert_eq!(data.len() % write_size, 0); | ||||
|         if off % write_size != 0 { | ||||
|             error!("Littlefs2Filesystem write error: offset not aligned to write size offset: {} write_size: {}", off, 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) => { | ||||
| @@ -49,15 +61,28 @@ impl lfs2Storage for LittleFs2Filesystem { | ||||
|  | ||||
|     fn erase(&mut self, off: usize, len: usize) -> lfs2Result<usize> { | ||||
|         let block_size: usize = Self::BLOCK_SIZE; | ||||
|         debug_assert!(off % block_size == 0); | ||||
|         debug_assert!(len % block_size == 0); | ||||
|         //match self.storage.erase(off as u32, len as u32) { | ||||
|         //anyhow::Result::Ok(..) => lfs2Result::Ok(len), | ||||
|         //Err(err) => { | ||||
|         //error!("Littlefs2Filesystem erase error: {:?}", err); | ||||
|         //Err(lfs2Error::IO) | ||||
|         //    } | ||||
|         //} | ||||
|         lfs2Result::Ok(len) | ||||
|         if off % block_size != 0 { | ||||
|             error!("Littlefs2Filesystem erase error: offset not aligned to block size offset: {} block_size: {}", off, block_size); | ||||
|             return lfs2Result::Err(lfs2Error::IO); | ||||
|         } | ||||
|         if len % block_size != 0 { | ||||
|             error!("Littlefs2Filesystem erase error: length not aligned to block size length: {} block_size: {}", len, block_size); | ||||
|             return lfs2Result::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 lfs2Result::Err(lfs2Error::IO); | ||||
|             } | ||||
|         } | ||||
|         match self.storage.erase(off as u32, (off + len) as u32) { | ||||
|             Ok(..) => lfs2Result::Ok(len), | ||||
|             Err(err) => { | ||||
|                 error!("Littlefs2Filesystem erase error: {:?}", err); | ||||
|                 lfs2Result::Err(lfs2Error::IO) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -44,7 +44,6 @@ use esp_hal::peripherals::GPIO8; | ||||
| use esp_hal::peripherals::TWAI0; | ||||
|  | ||||
| use crate::{ | ||||
|     bail, | ||||
|     config::{BatteryBoardVersion, BoardVersion, PlantControllerConfig}, | ||||
|     hal::{ | ||||
|         battery::{BatteryInteraction, NoBatteryMonitor}, | ||||
| @@ -70,7 +69,7 @@ use eeprom24x::{Eeprom24x, SlaveAddr, Storage}; | ||||
| use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice; | ||||
| use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; | ||||
| use embassy_sync::blocking_mutex::CriticalSectionMutex; | ||||
| use esp_bootloader_esp_idf::partitions::{AppPartitionSubType, DataPartitionSubType, Error, FlashRegion, PartitionEntry}; | ||||
| use esp_bootloader_esp_idf::partitions::{AppPartitionSubType, DataPartitionSubType, FlashRegion, PartitionEntry}; | ||||
| use esp_hal::clock::CpuClock; | ||||
| use esp_hal::gpio::{Input, InputConfig, Pull}; | ||||
| use measurements::{Current, Voltage}; | ||||
| @@ -84,8 +83,7 @@ use embassy_sync::mutex::Mutex; | ||||
| use embassy_sync::once_lock::OnceLock; | ||||
| use esp_alloc as _; | ||||
| use esp_backtrace as _; | ||||
| use esp_bootloader_esp_idf::ota::Slot; | ||||
| use esp_bootloader_esp_idf::ota::Slot::{Slot0, Slot1}; | ||||
| use esp_bootloader_esp_idf::ota::{Ota, OtaImageState, Slot}; | ||||
| use esp_hal::delay::Delay; | ||||
| use esp_hal::i2c::master::{BusTimeout, Config, I2c}; | ||||
| use esp_hal::pcnt::unit::Unit; | ||||
| @@ -100,7 +98,7 @@ use esp_storage::FlashStorage; | ||||
| use esp_wifi::{init, EspWifiController}; | ||||
| use littlefs2::fs::{Allocation, Filesystem as lfs2Filesystem}; | ||||
| use littlefs2::object_safe::DynStorage; | ||||
| use log::{info, warn}; | ||||
| use log::{error, info, warn}; | ||||
|  | ||||
| pub static TIME_ACCESS: OnceLock<Mutex<CriticalSectionRawMutex, Rtc>> = OnceLock::new(); | ||||
|  | ||||
| @@ -322,7 +320,11 @@ impl PlantHal { | ||||
|  | ||||
|         let mut ota = esp_bootloader_esp_idf::ota::Ota::new(ota_data)?; | ||||
|  | ||||
|         let next_slot = ota.current_slot()?.next(); | ||||
|         let state = ota.current_ota_state().unwrap_or_default(); | ||||
|         info!("Current OTA state: {:?}", state); | ||||
|  | ||||
|  | ||||
|         let next_slot = get_next_slot(&mut ota)?; | ||||
|         let ota_next = match next_slot { | ||||
|             Slot::None => { | ||||
|                 panic!("No OTA slot active?"); | ||||
| @@ -367,7 +369,7 @@ impl PlantHal { | ||||
|                     log::info!("Littlefs2 filesystem is formatted"); | ||||
|                 } | ||||
|                 Err(err) => { | ||||
|                     bail!("Littlefs2 filesystem could not be formatted: {:?}", err); | ||||
|                     error!("Littlefs2 filesystem could not be formatted: {:?}", err); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| @@ -567,6 +569,39 @@ impl PlantHal { | ||||
|  | ||||
|         Ok(Mutex::new(hal)) | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| fn get_next_slot(ota: &mut Ota<FlashStorage>) -> Result<Slot, FatError> { | ||||
|     let next_slot = { | ||||
|         let state = ota.current_ota_state().unwrap_or_default(); | ||||
|         let current = ota.current_slot()?; | ||||
|         match state { | ||||
|             OtaImageState::New => { | ||||
|                 current.next() | ||||
|             } | ||||
|             OtaImageState::PendingVerify => { | ||||
|                 current.next() | ||||
|             } | ||||
|             OtaImageState::Valid => { | ||||
|                 current.next() | ||||
|             } | ||||
|             OtaImageState::Invalid => { | ||||
|                 //we actually booted other slot, than partition table assumes | ||||
|                 current | ||||
|             } | ||||
|             OtaImageState::Aborted => { | ||||
|                 //we actually booted other slot, than partition table assumes | ||||
|                 current | ||||
|             } | ||||
|             OtaImageState::Undefined => { | ||||
|                 //missing bootloader? | ||||
|                 current.next() | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|     Ok(next_slot) | ||||
| } | ||||
|  | ||||
| pub async fn esp_time() -> DateTime<Utc> { | ||||
|   | ||||
| @@ -830,7 +830,7 @@ async fn publish_firmware_info( | ||||
|     let _ = esp.mqtt_publish("/firmware/last_online", timezone_time); | ||||
|     let state = esp.get_ota_state(); | ||||
|     let _ = esp.mqtt_publish("/firmware/ota_state", &state).await; | ||||
|     let slot = esp.get_ota_slot(); | ||||
|     let slot = esp.get_current_ota_slot(); | ||||
|     let _ = esp | ||||
|         .mqtt_publish("/firmware/ota_slot", &format!("slot{slot}")) | ||||
|         .await; | ||||
| @@ -1058,7 +1058,6 @@ async fn main(spawner: Spawner) -> ! { | ||||
|     // intialize embassy | ||||
|     logger::init_logger_from_env(); | ||||
|     //force init here! | ||||
|     println!("Hal init"); | ||||
|     match BOARD_ACCESS.init(PlantHal::create().await.unwrap()) { | ||||
|         Ok(_) => {} | ||||
|         Err(_) => { | ||||
| @@ -1097,11 +1096,13 @@ async fn get_version( | ||||
|     let hash = &env!("VERGEN_GIT_SHA")[0..8]; | ||||
|  | ||||
|     let board = board.board_hal.get_esp(); | ||||
|     let ota_slot = board.get_ota_slot(); | ||||
|     let ota_slot = board.get_current_ota_slot(); | ||||
|     let ota_state = board.get_ota_state(); | ||||
|     VersionInfo { | ||||
|         git_hash: branch + "@" + hash, | ||||
|         build_time: env!("VERGEN_BUILD_TIMESTAMP").to_owned(), | ||||
|         partition: ota_slot, | ||||
|         ota_state, | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -1110,4 +1111,5 @@ struct VersionInfo { | ||||
|     git_hash: String, | ||||
|     build_time: String, | ||||
|     partition: String, | ||||
|     ota_state: String, | ||||
| } | ||||
|   | ||||
| @@ -118,7 +118,7 @@ where | ||||
|             let mut offset = 0_usize; | ||||
|             let mut chunk = 0; | ||||
|             loop { | ||||
|                 let mut buf = [0_u8; 1024]; | ||||
|                 let mut buf = [0_u8; 4096]; | ||||
|                 let to_write = conn.read(&mut buf).await?; | ||||
|                 if to_write == 0 { | ||||
|                     info!("file request for {} finished", filename); | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| use core::str::FromStr; | ||||
| use crate::fat_error::{FatError, FatResult}; | ||||
| use crate::hal::{esp_time, PLANT_COUNT}; | ||||
| use crate::log::LogMessage; | ||||
| @@ -8,9 +9,9 @@ use alloc::format; | ||||
| use alloc::string::{String, ToString}; | ||||
| use alloc::vec::Vec; | ||||
| use chrono_tz::Tz; | ||||
| use core::str::FromStr; | ||||
| use edge_http::io::server::Connection; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
| use serde::Serialize; | ||||
|  | ||||
| #[derive(Serialize, Debug)] | ||||
| @@ -139,7 +140,24 @@ pub(crate) async fn get_time<T, const N: usize>( | ||||
| ) -> FatResult<Option<String>> { | ||||
|     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|     let conf = board.board_hal.get_config(); | ||||
|     let tz = Tz::from_str(conf.timezone.as_ref().unwrap().as_str()).unwrap(); | ||||
|  | ||||
|     let tz:Tz = match conf.timezone.as_ref(){ | ||||
|         None => { | ||||
|             Tz::UTC | ||||
|         } | ||||
|         Some(tz_string) => { | ||||
|             match Tz::from_str(tz_string) { | ||||
|                 Ok(tz) => { | ||||
|                     tz | ||||
|                 } | ||||
|                 Err(err) => { | ||||
|                     info!("failed parsing timezone {}", err); | ||||
|                     Tz::UTC | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let native = esp_time().await.with_timezone(&tz).to_rfc3339(); | ||||
|  | ||||
|     let rtc = match board.board_hal.get_rtc_module().get_rtc_time().await { | ||||
|   | ||||
| @@ -36,7 +36,7 @@ use edge_nal_embassy::{Tcp, TcpBuffers}; | ||||
| use embassy_net::Stack; | ||||
| use embassy_time::Instant; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| use log::info; | ||||
| use log::{error, info}; | ||||
| use crate::webserver::ota::ota_operations; | ||||
| // fn ota( | ||||
| //     request: &mut Request<&mut EspHttpConnection>, | ||||
| @@ -109,7 +109,11 @@ impl Handler for HTTPRequestRouter { | ||||
|         let status = if path.starts_with(prefix) { | ||||
|             file_operations(conn, method, &path, &prefix).await? | ||||
|         } else if path == "/ota" { | ||||
|             ota_operations(conn,method).await? | ||||
|             ota_operations(conn,method).await.map_err(|e| { | ||||
|                 error!("Error handling ota: {}", e); | ||||
|                     e | ||||
|                 } | ||||
|             )? | ||||
|         } else { | ||||
|             match method { | ||||
|                 Method::Get => match path { | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| use alloc::borrow::ToOwned; | ||||
| use alloc::format; | ||||
| use edge_http::io::server::Connection; | ||||
| use edge_http::Method; | ||||
| use embedded_io_async::{Read, Write}; | ||||
| @@ -31,6 +29,9 @@ where | ||||
|         Method::Post => { | ||||
|             let mut offset = 0_usize; | ||||
|             let mut chunk = 0; | ||||
|  | ||||
|             // Erase only a single 4K block right before writing into it. | ||||
|             // The first block will be erased when offset == 0 below. | ||||
|             loop { | ||||
|                 let mut buf = [0_u8; 1024]; | ||||
|                 let to_write = conn.read(&mut buf).await?; | ||||
| @@ -42,6 +43,11 @@ where | ||||
|                 } else { | ||||
|                     let mut board = BOARD_ACCESS.get().await.lock().await; | ||||
|                     board.board_hal.progress(chunk as u32).await; | ||||
|                     // Erase next block if we are at a 4K boundary (including the first block at offset 0) | ||||
|                     if offset % 4096 >= offset+to_write % 4096 { | ||||
|                         info!("erasing block {} during write between {} with size {}", offset / 4096, offset, to_write); | ||||
|                         board.board_hal.get_esp().ota_erase(offset as u32).await?; | ||||
|                     } | ||||
|                     board | ||||
|                         .board_hal | ||||
|                         .get_esp() | ||||
|   | ||||
| @@ -108,5 +108,5 @@ where | ||||
|     board.board_hal.get_esp().save_config(all).await?; | ||||
|     info!("Wrote config config {:?} with size {}", config, length); | ||||
|     board.board_hal.set_config(config); | ||||
|     Ok(Some("saved".to_string())) | ||||
|     Ok(Some("Ok".to_string())) | ||||
| } | ||||
|   | ||||
| @@ -157,7 +157,9 @@ export interface Moistures { | ||||
| export interface VersionInfo { | ||||
|     git_hash: string, | ||||
|     build_time: string, | ||||
|     partition: string | ||||
|     partition: string, | ||||
|     ota_state: string | ||||
|  | ||||
| } | ||||
|  | ||||
| export interface BatteryState { | ||||
|   | ||||
| @@ -31,6 +31,7 @@ import { | ||||
|     FileList, SolarState, PumpTestResult | ||||
| } from "./api"; | ||||
| import {SolarView} from "./solarview"; | ||||
| import {toast} from "./toast"; | ||||
|  | ||||
| export class Controller { | ||||
|     loadTankInfo(): Promise<void> { | ||||
| @@ -200,15 +201,22 @@ export class Controller { | ||||
|         }, false); | ||||
|         ajax.addEventListener("load", () => { | ||||
|             controller.progressview.removeProgress("ota_upload") | ||||
|             controller.reboot(); | ||||
|             const status = ajax.status; | ||||
|             if (status >= 200 && status < 300) { | ||||
|                 controller.reboot(); | ||||
|             } else { | ||||
|                 const statusText = ajax.statusText || ""; | ||||
|                 const body = ajax.responseText || ""; | ||||
|                 toast.error(`OTA update error (${status}${statusText ? ' ' + statusText : ''}): ${body}`); | ||||
|             } | ||||
|         }, false); | ||||
|         ajax.addEventListener("error", () => { | ||||
|             alert("Error ota") | ||||
|             controller.progressview.removeProgress("ota_upload") | ||||
|             toast.error("OTA upload failed due to a network error."); | ||||
|         }, false); | ||||
|         ajax.addEventListener("abort", () => { | ||||
|             alert("abort ota") | ||||
|             controller.progressview.removeProgress("ota_upload") | ||||
|             toast.error("OTA upload was aborted."); | ||||
|         }, false); | ||||
|         ajax.open("POST", PUBLIC_URL + "/ota"); | ||||
|         ajax.send(file); | ||||
|   | ||||
| @@ -31,6 +31,11 @@ | ||||
|   <span class="otakey">Partition:</span> | ||||
|   <span class="otavalue" id="firmware_partition"></span> | ||||
| </div> | ||||
| <div class="flexcontainer"> | ||||
|     <span class="otakey">Status:</span> | ||||
|     <span class="otavalue" id="firmware_state"></span> | ||||
| </div> | ||||
|  | ||||
| <div class="flexcontainer"> | ||||
|     <form class="otaform" id="upload_form" method="post"> | ||||
|         <input class="otachooser" type="file" name="file1" id="firmware_file"><br> | ||||
|   | ||||
| @@ -6,6 +6,7 @@ export class OTAView { | ||||
|     readonly firmware_buildtime: HTMLDivElement; | ||||
|     readonly firmware_githash: HTMLDivElement; | ||||
|     readonly firmware_partition: HTMLDivElement; | ||||
|     readonly firmware_state: HTMLDivElement; | ||||
|  | ||||
|     constructor(controller: Controller) { | ||||
|         (document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html") | ||||
| @@ -15,6 +16,7 @@ export class OTAView { | ||||
|         this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement; | ||||
|         this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement; | ||||
|         this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement; | ||||
|         this.firmware_state = document.getElementById("firmware_state") as HTMLDivElement; | ||||
|  | ||||
|          | ||||
|         const file = document.getElementById("firmware_file") as HTMLInputElement; | ||||
| @@ -37,5 +39,6 @@ export class OTAView { | ||||
|         this.firmware_buildtime.innerText = versionInfo.build_time; | ||||
|         this.firmware_githash.innerText = versionInfo.git_hash; | ||||
|         this.firmware_partition.innerText = versionInfo.partition; | ||||
|         this.firmware_state.innerText = versionInfo.ota_state; | ||||
|     } | ||||
| } | ||||
| @@ -26,6 +26,15 @@ export class SubmitView { | ||||
|         this.submit_status = document.getElementById("submit_status") as HTMLElement | ||||
|         this.submitFormBtn.onclick = () => { | ||||
|             controller.uploadConfig(this.json.textContent as string, (status: string) => { | ||||
|                 if (status != "OK") { | ||||
|                     // Show error toast (click to dismiss only) | ||||
|                     const { toast } = require('./toast'); | ||||
|                     toast.error(status); | ||||
|                 } else { | ||||
|                     // Show info toast (auto hides after 5s, or click to dismiss sooner) | ||||
|                     const { toast } = require('./toast'); | ||||
|                     toast.info('Config uploaded successfully'); | ||||
|                 } | ||||
|                 this.submit_status.innerHTML = status; | ||||
|             }); | ||||
|         } | ||||
|   | ||||
							
								
								
									
										93
									
								
								rust/src_webpack/src/toast.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								rust/src_webpack/src/toast.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | ||||
| class ToastService { | ||||
|   private container: HTMLElement; | ||||
|   private stylesInjected = false; | ||||
|  | ||||
|   constructor() { | ||||
|     this.container = this.ensureContainer(); | ||||
|     this.injectStyles(); | ||||
|   } | ||||
|  | ||||
|   info(message: string, timeoutMs: number = 5000) { | ||||
|     const el = this.createToast(message, 'info'); | ||||
|     this.container.appendChild(el); | ||||
|     // Auto-dismiss after timeout | ||||
|     const timer = window.setTimeout(() => this.dismiss(el), timeoutMs); | ||||
|     // Dismiss on click immediately | ||||
|     el.addEventListener('click', () => { | ||||
|       window.clearTimeout(timer); | ||||
|       this.dismiss(el); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   error(message: string) { | ||||
|     const el = this.createToast(message, 'error'); | ||||
|     this.container.appendChild(el); | ||||
|     // Only dismiss on click | ||||
|     el.addEventListener('click', () => this.dismiss(el)); | ||||
|   } | ||||
|  | ||||
|   private dismiss(el: HTMLElement) { | ||||
|     if (!el.parentElement) return; | ||||
|     el.parentElement.removeChild(el); | ||||
|   } | ||||
|  | ||||
|   private createToast(message: string, type: 'info' | 'error'): HTMLElement { | ||||
|     const div = document.createElement('div'); | ||||
|     div.className = `toast ${type}`; | ||||
|     div.textContent = message; | ||||
|     div.setAttribute('role', 'status'); | ||||
|     div.setAttribute('aria-live', 'polite'); | ||||
|     return div; | ||||
|   } | ||||
|  | ||||
|   private ensureContainer(): HTMLElement { | ||||
|     let container = document.getElementById('toast-container'); | ||||
|     if (!container) { | ||||
|       container = document.createElement('div'); | ||||
|       container.id = 'toast-container'; | ||||
|       document.body.appendChild(container); | ||||
|     } | ||||
|     return container; | ||||
|   } | ||||
|  | ||||
|   private injectStyles() { | ||||
|     if (this.stylesInjected) return; | ||||
|     const style = document.createElement('style'); | ||||
|     style.textContent = ` | ||||
| #toast-container { | ||||
|   position: fixed; | ||||
|   top: 12px; | ||||
|   right: 12px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 8px; | ||||
|   z-index: 9999; | ||||
| } | ||||
| .toast { | ||||
|   max-width: 320px; | ||||
|   padding: 10px 12px; | ||||
|   border-radius: 6px; | ||||
|   box-shadow: 0 2px 6px rgba(0,0,0,0.2); | ||||
|   cursor: pointer; | ||||
|   user-select: none; | ||||
|   font-family: sans-serif; | ||||
|   font-size: 14px; | ||||
|   line-height: 1.3; | ||||
| } | ||||
| .toast.info { | ||||
|   background-color: #d4edda; /* green-ish */ | ||||
|   color: #155724; | ||||
|   border-left: 4px solid #28a745; | ||||
| } | ||||
| .toast.error { | ||||
|   background-color: #f8d7da; /* red-ish */ | ||||
|   color: #721c24; | ||||
|   border-left: 4px solid #dc3545; | ||||
| } | ||||
| `; | ||||
|     document.head.appendChild(style); | ||||
|     this.stylesInjected = true; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const toast = new ToastService(); | ||||
		Reference in New Issue
	
	Block a user