15 Commits

Author SHA1 Message Date
c90174be27 fix actually spawn async task for webserver when entering stay_alive mode 2026-05-10 20:49:43 +02:00
b569fa4b04 Merge branch 'fix/reenable-plant-watering-wtf-empire-this-should-never-have-happend' into legacy/v3-support 2026-05-10 19:47:27 +02:00
f71ca7ec6d activate plant watering code again
for some idiotic the most critical part of the code was disabled and
commited that should not have happend!
2026-05-10 19:41:51 +02:00
28f7ae20ef Merge branch 'fix/ota-update' into legacy/v3-support 2026-05-10 17:36:32 +02:00
52049c456e update read_up_to_bytes_from_request with more robust implementation 2026-05-10 17:22:45 +02:00
e3b7648a3f website update version display and ota handler 2026-05-10 16:45:06 +02:00
08ee9018cf website add toast service for notifications 2026-05-10 16:44:30 +02:00
c2929a65ae enable ota_update webserver path again
take latest ota code from `develop` branch and apply to legacy branch
2026-05-10 16:34:26 +02:00
0b386b0ca3 Merge branch 'feature/build-scripts-from-develop' into legacy/v3-support 2026-05-10 14:39:37 +02:00
0ab1ea3635 build: add build artifacts to .gitignore 2026-05-10 14:39:24 +02:00
ae73f12d1c build: add image_build.sh and erase_ota.sh scripts 2026-05-10 14:39:22 +02:00
cfe1c2c6d8 build: add flash.sh and all.sh scripts 2026-05-10 14:39:20 +02:00
578379c0d9 build: remove webpack integration from build.rs and add always-rebuild sentinel 2026-05-10 14:39:19 +02:00
2ff219a1cb build: add reusable build_website.sh script 2026-05-10 14:39:16 +02:00
96023c8dc3 Merge branch 'refactor/mkstatic-util' into legacy/v3-support 2026-05-10 14:04:25 +02:00
14 changed files with 459 additions and 227 deletions

9
.gitignore vendored
View File

@@ -8,6 +8,15 @@ target
Cargo.lock Cargo.lock
node_modules/ node_modules/
rust/src/webserver/bundle.js rust/src/webserver/bundle.js
rust/src/webserver/bundle.js.gz
rust/src/webserver/index.html rust/src/webserver/index.html
rust/src/webserver/index.html.gz
rust/src_webpack/bundle.js
rust/src_webpack/bundle.js.gz
rust/src_webpack/index.html
rust/src_webpack/index.html.gz
rust/build/ rust/build/
rust/image.bin rust/image.bin
rust/target/
rust/Cargo.lock
rust/src_webpack/node_modules/

22
rust/all.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/build_website.sh"
cargo build --release
espflash save-image \
--bootloader "${SCRIPT_DIR}/bootloader.bin" \
--partition-table "${SCRIPT_DIR}/partitions.csv" \
--chip esp32c6 \
target/riscv32imac-unknown-none-elf/release/plant-ctrl2 \
"${SCRIPT_DIR}/image.bin"
espflash flash --monitor \
--bootloader "${SCRIPT_DIR}/bootloader.bin" \
--chip esp32c6 \
--baud 921600 \
--partition-table "${SCRIPT_DIR}/partitions.csv" \
target/riscv32imac-unknown-none-elf/release/plant-ctrl2

View File

@@ -1,5 +1,3 @@
use std::process::Command;
use vergen::EmitBuilder; use vergen::EmitBuilder;
fn linker_be_nice() { fn linker_be_nice() {
@@ -50,72 +48,9 @@ fn linker_be_nice() {
} }
fn main() { fn main() {
webpack();
linker_be_nice(); linker_be_nice();
// Non-existent path causes Cargo to always re-run this script,
// keeping VERGEN_BUILD_TIMESTAMP fresh on every build.
println!("cargo:rerun-if-changed=ALWAYS_REBUILD_SENTINEL");
let _ = EmitBuilder::builder().all_git().all_build().emit(); let _ = EmitBuilder::builder().all_git().all_build().emit();
} }
fn webpack() {
//println!("cargo:rerun-if-changed=./src/src_webpack");
Command::new("rm")
.arg("./src/webserver/bundle.js.gz")
.output()
.unwrap();
match Command::new("cmd").spawn() {
Ok(_) => {
println!("Assuming build on windows");
let output = Command::new("cmd")
.arg("/K")
.arg("npx")
.arg("webpack")
.current_dir("./src_webpack")
.output()
.unwrap();
println!("status: {}", output.status);
println!("stdout: {}", String::from_utf8_lossy(&output.stdout));
println!("stderr: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.status.success());
// move webpack results to rust webserver src
let _ = Command::new("cmd")
.arg("/K")
.arg("move")
.arg("./src_webpack/bundle.js.gz")
.arg("./src/webserver")
.output()
.unwrap();
let _ = Command::new("cmd")
.arg("/K")
.arg("move")
.arg("./src_webpack/index.html.gz")
.arg("./src/webserver")
.output()
.unwrap();
}
Err(_) => {
println!("Assuming build on linux");
let output = Command::new("npx")
.arg("webpack")
.current_dir("./src_webpack")
.output()
.unwrap();
println!("status: {}", output.status);
println!("stdout: {}", String::from_utf8_lossy(&output.stdout));
println!("stderr: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.status.success());
// move webpack results to rust webserver src
let _ = Command::new("mv")
.arg("./src_webpack/bundle.js.gz")
.arg("./src/webserver")
.output()
.unwrap();
let _ = Command::new("mv")
.arg("./src_webpack/index.html.gz")
.arg("./src/webserver")
.output()
.unwrap();
}
}
}

21
rust/build_website.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEBPACK_DIR="${SCRIPT_DIR}/src_webpack"
WEBSERVER_DIR="${SCRIPT_DIR}/src/webserver"
rm -f "${WEBSERVER_DIR}/index.html.gz"
rm -f "${WEBSERVER_DIR}/bundle.js.gz"
rm -f "${WEBPACK_DIR}/index.html.gz"
rm -f "${WEBPACK_DIR}/bundle.js.gz"
rm -f "${WEBPACK_DIR}/index.html"
rm -f "${WEBPACK_DIR}/bundle.js"
pushd "${WEBPACK_DIR}"
npm install
npx webpack build
cp index.html.gz "${WEBSERVER_DIR}/index.html.gz"
cp bundle.js.gz "${WEBSERVER_DIR}/bundle.js.gz"
popd

7
rust/erase_ota.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cargo espflash erase-parts otadata --partition-table "${SCRIPT_DIR}/partitions.csv"

15
rust/flash.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"${SCRIPT_DIR}/build_website.sh"
cargo build --release
espflash flash --monitor \
--bootloader "${SCRIPT_DIR}/bootloader.bin" \
--chip esp32c6 \
--baud 921600 \
--partition-table "${SCRIPT_DIR}/partitions.csv" \
target/riscv32imac-unknown-none-elf/release/plant-ctrl2

17
rust/image_build.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
rm -f "${SCRIPT_DIR}/image.bin"
"${SCRIPT_DIR}/build_website.sh"
cargo build --release
espflash save-image \
--bootloader "${SCRIPT_DIR}/bootloader.bin" \
--partition-table "${SCRIPT_DIR}/partitions.csv" \
--chip esp32c6 \
target/riscv32imac-unknown-none-elf/release/plant-ctrl2 \
"${SCRIPT_DIR}/image.bin"

View File

@@ -26,7 +26,7 @@ use crate::{
config::BoardVersion::INITIAL, config::BoardVersion::INITIAL,
hal::{PlantHal, HAL, PLANT_COUNT}, hal::{PlantHal, HAL, PLANT_COUNT},
}; };
use ::log::{info, warn, error}; use ::log::{error, info, warn};
use alloc::borrow::ToOwned; use alloc::borrow::ToOwned;
use alloc::string::{String, ToString}; use alloc::string::{String, ToString};
use alloc::sync::Arc; use alloc::sync::Arc;
@@ -122,8 +122,6 @@ pub struct PumpResult {
pump_time_s: u16, pump_time_s: u16,
} }
async fn safe_main(spawner: Spawner) -> FatResult<()> { async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("Startup Rust"); info!("Startup Rust");
@@ -197,10 +195,15 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("No wifi configured, starting initial config mode"); info!("No wifi configured, starting initial config mode");
let esp = board.board_hal.get_esp(); let esp = board.board_hal.get_esp();
let ssid = esp.load_config().await let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string()) .map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode")); .unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = esp.interface_ap.take().context("AP interface already taken")?; let device = esp
.interface_ap
.take()
.context("AP interface already taken")?;
let stack = network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await?; let stack = network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await?;
let reboot_now = Arc::new(AtomicBool::new(false)); let reboot_now = Arc::new(AtomicBool::new(false));
@@ -225,14 +228,18 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let res = { let res = {
let esp = board.board_hal.get_esp(); let esp = board.board_hal.get_esp();
let ssid = esp.load_config().await let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string()) .map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode")); .unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = match esp.interface_ap.take() { let device = match esp.interface_ap.take() {
Some(d) => d, Some(d) => d,
None => { None => {
use crate::fat_error::FatError; use crate::fat_error::FatError;
return Err(FatError::String { error: "AP interface already taken".to_string() }); return Err(FatError::String {
error: "AP interface already taken".to_string(),
});
} }
}; };
network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await
@@ -302,7 +309,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
log(LogMessage::NormalRun, 0, 0, "", ""); log(LogMessage::NormalRun, 0, 0, "", "");
} }
let _dry_run = false; let dry_run = false;
let tank_state = determine_tank_state(&mut board).await; let tank_state = determine_tank_state(&mut board).await;
@@ -339,7 +346,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
} }
} }
let mut _water_frozen = false; let mut water_frozen = false;
let water_temp: FatResult<f32> = match board.board_hal.get_tank_sensor() { let water_temp: FatResult<f32> = match board.board_hal.get_tank_sensor() {
Ok(sensor) => sensor.water_temperature_c().await, Ok(sensor) => sensor.water_temperature_c().await,
Err(e) => Err(e), Err(e) => Err(e),
@@ -347,7 +354,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
if let Ok(res) = water_temp { if let Ok(res) = water_temp {
if res < WATER_FROZEN_THRESH { if res < WATER_FROZEN_THRESH {
_water_frozen = true; water_frozen = true;
} }
} }
info!("Water temp is {}", water_temp.as_ref().unwrap_or(&0.)); info!("Water temp is {}", water_temp.as_ref().unwrap_or(&0.));
@@ -367,74 +374,70 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
publish_plant_states(&mut board, &timezone_time.clone(), &plantstate).await; publish_plant_states(&mut board, &timezone_time.clone(), &plantstate).await;
// let pump_required = plantstate let pump_required = plantstate
// .iter() .iter()
// .zip(&board.board_hal.get_config().plants) .zip(&board.board_hal.get_config().plants)
// .any(|(it, conf)| it.needs_to_be_watered(conf, &timezone_time)) .any(|(it, conf)| it.needs_to_be_watered(conf, &timezone_time))
// && !water_frozen; && !water_frozen;
// if pump_required { if pump_required {
// log(LogMessage::EnableMain, dry_run as u32, 0, "", ""); log(LogMessage::EnableMain, dry_run as u32, 0, "", "");
// for (plant_id, (state, plant_config)) in plantstate for (plant_id, (state, plant_config)) in plantstate
// .iter() .iter()
// .zip(&board.board_hal.get_config().plants.clone()) .zip(&board.board_hal.get_config().plants.clone())
// .enumerate() .enumerate()
// { {
// if state.needs_to_be_watered(plant_config, &timezone_time) { if state.needs_to_be_watered(plant_config, &timezone_time) {
// let pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id) + 1; let pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id) + 1;
// board board
// .board_hal .board_hal
// .get_esp() .get_esp()
// .store_consecutive_pump_count(plant_id, pump_count); .store_consecutive_pump_count(plant_id, pump_count);
// let pump_ineffective = pump_count > plant_config.max_consecutive_pump_count as u32;
// let pump_ineffective = pump_count > plant_config.max_consecutive_pump_count as u32; if pump_ineffective {
// if pump_ineffective { log(
// log( LogMessage::ConsecutivePumpCountLimit,
// LogMessage::ConsecutivePumpCountLimit, pump_count,
// pump_count, plant_config.max_consecutive_pump_count as u32,
// plant_config.max_consecutive_pump_count as u32, &(plant_id + 1).to_string(),
// &(plant_id + 1).to_string(), "",
// "", );
// ); board.board_hal.fault(plant_id, true).await?;
// board.board_hal.fault(plant_id, true).await?; }
// } log(
// log( LogMessage::PumpPlant,
// LogMessage::PumpPlant, (plant_id + 1) as u32,
// (plant_id + 1) as u32, plant_config.pump_time_s as u32,
// plant_config.pump_time_s as u32, &dry_run.to_string(),
// &dry_run.to_string(), "",
// "", );
// ); board
// board .board_hal
// .board_hal .get_esp()
// .get_esp() .store_last_pump_time(plant_id, cur);
// .store_last_pump_time(plant_id, cur); board.board_hal.get_esp().last_pump_time(plant_id);
// board.board_hal.get_esp().last_pump_time(plant_id); pump_info(plant_id, true, pump_ineffective, 0, 0, 0, false).await;
// //state.active = true; let result = do_secure_pump(&mut board, plant_id, plant_config, dry_run).await?;
// board.board_hal.pump(plant_id, false).await?;
// pump_info(plant_id, true, pump_ineffective, 0, 0, 0, false).await; pump_info(
// plant_id,
// let result = do_secure_pump(plant_id, plant_config, dry_run).await?; false,
// board.board_hal.pump(plant_id, false).await?; pump_ineffective,
// pump_info( result.median_current_ma,
// plant_id, result.max_current_ma,
// false, result.min_current_ma,
// pump_ineffective, result.error,
// result.median_current_ma, )
// result.max_current_ma, .await;
// result.min_current_ma, } else if !state.pump_in_timeout(plant_config, &timezone_time) {
// result.error, // plant does not need to be watered and is not in timeout
// ) // -> reset consecutive pump count
// .await; board
// } else if !state.pump_in_timeout(plant_config, &timezone_time) { .board_hal
// // plant does not need to be watered and is not in timeout .get_esp()
// // -> reset consecutive pump count .store_consecutive_pump_count(plant_id, 0);
// board }
// .board_hal }
// .get_esp() }
// .store_consecutive_pump_count(plant_id, 0);
// }
// }
// }
info!("state of charg"); info!("state of charg");
let is_day = board.board_hal.is_day(); let is_day = board.board_hal.is_day();
@@ -558,7 +561,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
if stay_alive { if stay_alive {
let reboot_now = Arc::new(AtomicBool::new(false)); let reboot_now = Arc::new(AtomicBool::new(false));
let _webserver = http_server(reboot_now.clone(), stack.take().unwrap());
spawner.spawn(http_server(reboot_now.clone(), stack.take().unwrap())?);
wait_infinity(board, WaitType::MqttConfig, reboot_now.clone(), UTC).await; wait_infinity(board, WaitType::MqttConfig, reboot_now.clone(), UTC).await;
} else { } else {
//TODO wait for all mqtt publishes? //TODO wait for all mqtt publishes?

View File

@@ -30,59 +30,80 @@ use core::result::Result::Ok;
use core::sync::atomic::{AtomicBool, Ordering}; use core::sync::atomic::{AtomicBool, Ordering};
use edge_http::io::server::{Connection, Handler, Server}; use edge_http::io::server::{Connection, Handler, Server};
use edge_http::Method; use edge_http::Method;
use edge_nal::TcpBind;
use edge_nal::io::{Read, Write}; use edge_nal::io::{Read, Write};
use edge_nal::TcpBind;
use edge_nal_embassy::{Tcp, TcpBuffers}; use edge_nal_embassy::{Tcp, TcpBuffers};
use embassy_net::Stack; use embassy_net::Stack;
use embassy_time::Instant; use embassy_time::Instant;
use log::info; use log::{error, info};
// fn ota( pub(crate) async fn ota_operations<T, const N: usize>(
// request: &mut Request<&mut EspHttpConnection>, conn: &mut Connection<'_, T, { N }>,
// ) -> Result<Option<std::string::String>, anyhow::Error> { method: Method,
// let mut board = BOARD_ACCESS.lock().unwrap(); ) -> Result<Option<u32>, FatError>
// let mut ota = OtaUpdate::begin()?; where
// log::info!("start ota"); T: Read + Write,
// {
// //having a larger buffer is not really faster, requires more stack and prevents the progress bar from working ;) Ok(match method {
// const BUFFER_SIZE: usize = 512; Method::Options => {
// let mut buffer: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE]; conn.initiate_response(
// let mut total_read: usize = 0; 200,
// let mut lastiter = 0; Some("OK"),
// loop { &[
// let read = request.read(&mut buffer)?; ("Access-Control-Allow-Origin", "*"),
// total_read += read; ("Access-Control-Allow-Headers", "*"),
// let to_write = &buffer[0..read]; ("Access-Control-Allow-Methods", "*"),
// //delay for watchdog and wifi stuff ],
// board.board_hal.get_esp().delay.delay_ms(1); )
// .await?;
// let iter = (total_read / 1024) % 8; Some(200)
// if iter != lastiter { }
// board.board_hal.general_fault(iter % 5 == 0); Method::Post => {
// for i in 0..PLANT_COUNT { let mut offset = 0_usize;
// let _ = board.board_hal.fault(i, iter == i); let mut chunk = 0;
// } loop {
// lastiter = iter; let buf = read_up_to_bytes_from_request(conn, Some(4096)).await?;
// } if buf.is_empty() {
// info!("file request for ota finished");
// ota.write(to_write)?; let mut board = BOARD_ACCESS.get().await.lock().await;
// if read == 0 { board.board_hal.get_esp().finalize_ota().await?;
// break; break;
// } } else {
// } let mut board = BOARD_ACCESS.get().await.lock().await;
// log::info!("wrote bytes ota {total_read}"); board.board_hal.progress(chunk as u32).await;
// log::info!("finish ota"); // Erase next block if we are at a 4K boundary (including the first block at offset 0)
// let partition = ota.raw_partition(); board
// log::info!("finalizing and changing boot partition to {partition:?}"); .board_hal
// .get_esp()
// let mut finalizer = ota.finalize()?; .write_ota(offset as u32, &buf)
// log::info!("changing boot partition"); .await?;
// board.board_hal.get_esp().set_restart_to_conf(true); }
// drop(board); offset += buf.len();
// finalizer.set_as_boot_partition()?; chunk += 1;
// anyhow::Ok(None) }
// } BOARD_ACCESS
// .get()
.await
.lock()
.await
.board_hal
.clear_progress()
.await;
conn.initiate_response(
200,
Some("OK"),
&[
("Access-Control-Allow-Origin", "*"),
("Access-Control-Allow-Headers", "*"),
("Access-Control-Allow-Methods", "*"),
],
)
.await?;
Some(200)
}
_ => None,
})
}
struct HTTPRequestRouter { struct HTTPRequestRouter {
reboot_now: Arc<AtomicBool>, reboot_now: Arc<AtomicBool>,
@@ -105,7 +126,12 @@ impl Handler for HTTPRequestRouter {
let path = headers.path; let path = headers.path;
let prefix = "/file?filename="; let prefix = "/file?filename=";
let status = if path.starts_with(prefix) { let status = if path == "/ota" {
ota_operations(conn, method).await.map_err(|e| {
error!("Error handling ota: {e}");
e
})?
} else if path.starts_with(prefix) {
file_operations(conn, method, &path, &prefix).await? file_operations(conn, method, &path, &prefix).await?
} else { } else {
match method { match method {
@@ -195,12 +221,18 @@ where
let mut data_store = Vec::new(); let mut data_store = Vec::new();
let mut total_read = 0; let mut total_read = 0;
loop { loop {
let left = max_read - total_read;
let mut buf = [0_u8; 64]; let mut buf = [0_u8; 64];
let read = request.read(&mut buf).await?; let s_buf = if buf.len() <= left {
&mut buf
} else {
&mut buf[0..left]
};
let read = request.read(s_buf).await?;
if read == 0 { if read == 0 {
break; break;
} }
let actual_data = &buf[0..read]; let actual_data = &s_buf[0..read];
total_read += read; total_read += read;
if total_read > max_read { if total_read > max_read {
bail!("Request too large {total_read} > {max_read}"); bail!("Request too large {total_read} > {max_read}");

View File

@@ -157,7 +157,13 @@ export interface Moistures {
export interface VersionInfo { export interface VersionInfo {
git_hash: string, git_hash: string,
build_time: string, build_time: string,
partition: string current: string,
slot0_state: string,
slot1_state: string,
heap_total: number,
heap_used: number,
heap_free: number,
heap_max_used: number,
} }
export interface BatteryState { export interface BatteryState {

View File

@@ -31,6 +31,7 @@ import {
FileList, SolarState, PumpTestResult FileList, SolarState, PumpTestResult
} from "./api"; } from "./api";
import {SolarView} from "./solarview"; import {SolarView} from "./solarview";
import {toast} from "./toast";
export class Controller { export class Controller {
loadTankInfo(): Promise<void> { loadTankInfo(): Promise<void> {
@@ -200,15 +201,22 @@ export class Controller {
}, false); }, false);
ajax.addEventListener("load", () => { ajax.addEventListener("load", () => {
controller.progressview.removeProgress("ota_upload") controller.progressview.removeProgress("ota_upload")
const status = ajax.status;
if (status >= 200 && status < 300) {
controller.reboot(); controller.reboot();
} else {
const statusText = ajax.statusText || "";
const body = ajax.responseText || "";
toast.error(`OTA update error (${status}${statusText ? ' ' + statusText : ''}): ${body}`);
}
}, false); }, false);
ajax.addEventListener("error", () => { ajax.addEventListener("error", () => {
alert("Error ota")
controller.progressview.removeProgress("ota_upload") controller.progressview.removeProgress("ota_upload")
toast.error("OTA upload failed due to a network error.");
}, false); }, false);
ajax.addEventListener("abort", () => { ajax.addEventListener("abort", () => {
alert("abort ota")
controller.progressview.removeProgress("ota_upload") controller.progressview.removeProgress("ota_upload")
toast.error("OTA upload was aborted.");
}, false); }, false);
ajax.open("POST", PUBLIC_URL + "/ota"); ajax.open("POST", PUBLIC_URL + "/ota");
ajax.send(file); ajax.send(file);

View File

@@ -1,14 +1,17 @@
<style> <style>
.otakey{ .otakey {
min-width: 100px; min-width: 100px;
} }
.otavalue{
.otavalue {
flex-grow: 1; flex-grow: 1;
} }
.otaform { .otaform {
min-width: 100px; min-width: 100px;
flex-grow: 1; flex-grow: 1;
} }
.otachooser { .otachooser {
min-width: 100px; min-width: 100px;
width: 100%; width: 100%;
@@ -18,6 +21,7 @@
<div class="subtitle"> <div class="subtitle">
Current Firmware Current Firmware
</div> </div>
<button style="margin-left: auto;" type="button" id="refresh_firmware_info">Refresh</button>
</div> </div>
<div class="flexcontainer"> <div class="flexcontainer">
<span class="otakey">Buildtime:</span> <span class="otakey">Buildtime:</span>
@@ -31,11 +35,43 @@
<span class="otakey">Partition:</span> <span class="otakey">Partition:</span>
<span class="otavalue" id="firmware_partition"></span> <span class="otavalue" id="firmware_partition"></span>
</div> </div>
<div class="flexcontainer">
<span class="otakey">State0:</span>
<span class="otavalue" id="firmware_state0"></span>
</div>
<div class="flexcontainer">
<span class="otakey">State1:</span>
<span class="otavalue" id="firmware_state1"></span>
</div>
<div class="flexcontainer"> <div class="flexcontainer">
<form class="otaform" id="upload_form" method="post"> <form class="otaform" id="upload_form" method="post">
<input class="otachooser" type="file" name="file1" id="firmware_file"><br> <input class="otachooser" type="file" name="file1" id="firmware_file"><br>
</form> </form>
</div> </div>
<div class="flexcontainer">
<div class="subtitle">
Heap Memory
</div>
<div></div>
</div>
<div class="flexcontainer">
<span class="otakey">Free:</span>
<span class="otavalue" id="heap_free"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Used:</span>
<span class="otavalue" id="heap_used"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Total:</span>
<span class="otavalue" id="heap_total"></span>
</div>
<div class="flexcontainer">
<span class="otakey">Peak used:</span>
<span class="otavalue" id="heap_max_used"></span>
</div>
<div class="display:flex"> <div class="display:flex">
<button style="margin-left: 16px; margin-top: 8px;" class="col-6" type="button" id="test">Self-Test</button> <button style="margin-left: 16px; margin-top: 8px;" class="col-6" type="button" id="test">Self-Test</button>
</div> </div>

View File

@@ -1,21 +1,37 @@
import { Controller } from "./main"; import {Controller} from "./main";
import {VersionInfo} from "./api"; import {VersionInfo} from "./api";
function fmtBytes(n: number): string {
return `${n} B (${(n / 1024).toFixed(1)} KiB)`;
}
export class OTAView { export class OTAView {
readonly file1Upload: HTMLInputElement; readonly file1Upload: HTMLInputElement;
readonly firmware_buildtime: HTMLDivElement; readonly firmware_buildtime: HTMLDivElement;
readonly firmware_githash: HTMLDivElement; readonly firmware_githash: HTMLDivElement;
readonly firmware_partition: HTMLDivElement; readonly firmware_partition: HTMLDivElement;
readonly firmware_state0: HTMLDivElement;
readonly firmware_state1: HTMLDivElement;
readonly heap_free: HTMLDivElement;
readonly heap_used: HTMLDivElement;
readonly heap_total: HTMLDivElement;
readonly heap_max_used: HTMLDivElement;
constructor(controller: Controller) { constructor(controller: Controller) {
(document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html") (document.getElementById("firmwareview") as HTMLElement).innerHTML = require("./ota.html")
let test = document.getElementById("test") as HTMLButtonElement; let test = document.getElementById("test") as HTMLButtonElement;
let refresh = document.getElementById("refresh_firmware_info") as HTMLButtonElement;
this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement; this.firmware_buildtime = document.getElementById("firmware_buildtime") as HTMLDivElement;
this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement; this.firmware_githash = document.getElementById("firmware_githash") as HTMLDivElement;
this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement; this.firmware_partition = document.getElementById("firmware_partition") as HTMLDivElement;
this.firmware_state0 = document.getElementById("firmware_state0") as HTMLDivElement;
this.firmware_state1 = document.getElementById("firmware_state1") as HTMLDivElement;
this.heap_free = document.getElementById("heap_free") as HTMLDivElement;
this.heap_used = document.getElementById("heap_used") as HTMLDivElement;
this.heap_total = document.getElementById("heap_total") as HTMLDivElement;
this.heap_max_used = document.getElementById("heap_max_used") as HTMLDivElement;
const file = document.getElementById("firmware_file") as HTMLInputElement; const file = document.getElementById("firmware_file") as HTMLInputElement;
this.file1Upload = file this.file1Upload = file
@@ -31,11 +47,21 @@ export class OTAView {
test.onclick = () => { test.onclick = () => {
controller.selfTest(); controller.selfTest();
} }
refresh.onclick = () => {
controller.version();
}
} }
setVersion(versionInfo: VersionInfo) { setVersion(versionInfo: VersionInfo) {
this.firmware_buildtime.innerText = versionInfo.build_time; this.firmware_buildtime.innerText = versionInfo.build_time;
this.firmware_githash.innerText = versionInfo.git_hash; this.firmware_githash.innerText = versionInfo.git_hash;
this.firmware_partition.innerText = versionInfo.partition; this.firmware_partition.innerText = versionInfo.current;
this.firmware_state0.innerText = versionInfo.slot0_state;
this.firmware_state1.innerText = versionInfo.slot1_state;
this.heap_free.innerText = fmtBytes(versionInfo.heap_free);
this.heap_used.innerText = fmtBytes(versionInfo.heap_used);
this.heap_total.innerText = fmtBytes(versionInfo.heap_total);
this.heap_max_used.innerText = fmtBytes(versionInfo.heap_max_used);
} }
} }

View File

@@ -0,0 +1,94 @@
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) {
console.error(message);
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();