This commit is contained in:
2025-10-04 01:24:00 +02:00
parent 27b18df78e
commit 0ddf6a6886
30 changed files with 2863 additions and 81 deletions

11
bootloader/CMakeLists.txt Normal file
View 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
View 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 doesnt 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
View 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

View File

@@ -0,0 +1 @@
idf_component_register(SRCS "dummy.c" INCLUDE_DIRS ".")

4
bootloader/main/dummy.c Normal file
View 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) {}

View 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,
1 nvs data nvs 16k
2 otadata data ota 8k
3 phy_init data phy 4k
4 ota_0 app ota_0 3968k
5 ota_1 app ota_1 3968k
6 storage data littlefs 8M

2385
bootloader/sdkconfig Normal file

File diff suppressed because it is too large Load Diff

View 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"

View File

@@ -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"

View File

@@ -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

Binary file not shown.

View File

@@ -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
View 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
View 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

View File

@@ -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()
}
}
}

View File

@@ -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);

View File

@@ -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)
}
}
}
}

View File

@@ -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> {

View File

@@ -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,
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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()))
}

View File

@@ -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 {

View File

@@ -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);

View 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>

View File

@@ -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;
}
}

View File

@@ -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;
});
}

View 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();