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