diff --git a/Hardware/Sensor_Case/Body_v2.3mf b/Hardware/Sensor_Case/Body_v2.3mf new file mode 100644 index 0000000..7b8470a Binary files /dev/null and b/Hardware/Sensor_Case/Body_v2.3mf differ diff --git a/Software/MainBoard/rust/erase_ota.sh b/Software/MainBoard/rust/erase_ota.sh new file mode 100755 index 0000000..7e19270 --- /dev/null +++ b/Software/MainBoard/rust/erase_ota.sh @@ -0,0 +1 @@ +cargo espflash erase-parts otadata --partition-table partitions.csv diff --git a/Software/MainBoard/rust/panic_image.bin b/Software/MainBoard/rust/panic_image.bin new file mode 100644 index 0000000..74bd6d9 Binary files /dev/null and b/Software/MainBoard/rust/panic_image.bin differ diff --git a/Software/MainBoard/rust/src/hal/battery.rs b/Software/MainBoard/rust/src/hal/battery.rs index 8de40c4..d723fb9 100644 --- a/Software/MainBoard/rust/src/hal/battery.rs +++ b/Software/MainBoard/rust/src/hal/battery.rs @@ -18,7 +18,7 @@ pub trait BatteryInteraction { async fn reset(&mut self) -> FatResult<()>; } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, Copy, Clone)] pub struct BatteryInfo { pub voltage_milli_volt: u32, pub average_current_milli_ampere: i32, diff --git a/Software/MainBoard/rust/src/hal/esp.rs b/Software/MainBoard/rust/src/hal/esp.rs index 3a69e66..5923a53 100644 --- a/Software/MainBoard/rust/src/hal/esp.rs +++ b/Software/MainBoard/rust/src/hal/esp.rs @@ -320,16 +320,16 @@ impl Esp<'_> { let ntp_addrs = stack .dns_query(NTP_SERVER, DnsQueryType::A) - .await - .expect("Failed to resolve DNS"); - if ntp_addrs.is_empty() { + .await; + if ntp_addrs.is_err() { bail!("Failed to resolve DNS"); } - info!("NTP server: {ntp_addrs:?}"); + let ntp = ntp_addrs.unwrap()[0]; + info!("NTP server: {ntp:?}"); let mut counter = 0; loop { - let addr: IpAddr = ntp_addrs[0].into(); + let addr: IpAddr = ntp.into(); let timeout = get_time(SocketAddr::from((addr, 123)), &socket, context) .with_timeout(Duration::from_millis((_max_wait_ms / 10) as u64)) .await; diff --git a/Software/MainBoard/rust/src/hal/v4_hal.rs b/Software/MainBoard/rust/src/hal/v4_hal.rs index 8d924d7..6551340 100644 --- a/Software/MainBoard/rust/src/hal/v4_hal.rs +++ b/Software/MainBoard/rust/src/hal/v4_hal.rs @@ -357,6 +357,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> { } async fn measure_moisture_hz(&mut self) -> FatResult { self.can_power.set_high(); + Timer::after_millis(500).await; let config = self.twai_config.take().expect("twai config not set"); let mut twai = config.into_async().start(); @@ -364,7 +365,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> { let mut moistures = Moistures::default(); let _ = wait_for_can_measurements(&mut twai, &mut moistures) - .with_timeout(Duration::from_millis(5000)) + .with_timeout(Duration::from_millis(1000)) .await; let config = twai.stop().into_blocking(); @@ -374,6 +375,74 @@ impl<'a> BoardInteraction<'a> for V4<'a> { Ok(moistures) } + async fn detect_sensors(&mut self, request: Detection) -> FatResult { + self.can_power.set_high(); + Timer::after_millis(500).await; + let config = self.twai_config.take().expect("twai config not set"); + let mut twai = config.into_async().start(); + + Timer::after_millis(1000).await; + info!("Sending info messages now"); + // Send a few test messages per potential sensor node + for plant in 0..PLANT_COUNT { + for sensor in [Sensor::A, Sensor::B] { + let detect = if sensor == Sensor::A { + request.plant[plant].sensor_a + } else { + request.plant[plant].sensor_b + }; + if !detect { + continue; + } + let target = StandardId::new(plant_id( + IDENTIFY_CMD_OFFSET, + sensor.into(), + (plant + 1) as u16, + )) + .context(">> Could not create address for sensor! (plant: {}) <<")?; + let can_buffer = [0_u8; 0]; + info!( + "Sending test message to plant {} sensor {sensor:?} with id {}", + plant + 1, + target.as_raw() + ); + if let Some(frame) = EspTwaiFrame::new(target, &can_buffer) { + // Try a few times; we intentionally ignore rx here and rely on stub logic + let resu = twai + .transmit_async(&frame) + .with_timeout(Duration::from_millis(500)) + .await; + match resu { + Ok(_) => {} + Err(err) => { + info!( + "Error sending test message to plant {} sensor {sensor:?}: {err:?}", + plant + 1 + ); + } + } + } else { + info!("Error building CAN frame"); + } + } + } + + let mut moistures = Moistures::default(); + let _ = wait_for_can_measurements(&mut twai, &mut moistures) + .with_timeout(Duration::from_millis(3000)) + .await; + + let config = twai.stop().into_blocking(); + self.twai_config.replace(config); + + self.can_power.set_low(); + + let result = moistures.into(); + + info!("Autodetection result: {result:?}"); + Ok(result) + } + async fn general_fault(&mut self, enable: bool) { hold_disable(23); self.general_fault.set_level(enable.into()); @@ -443,73 +512,6 @@ impl<'a> BoardInteraction<'a> for V4<'a> { } Ok(()) } - - async fn detect_sensors(&mut self, request: Detection) -> FatResult { - self.can_power.set_high(); - let config = self.twai_config.take().expect("twai config not set"); - let mut twai = config.into_async().start(); - - Timer::after_millis(1000).await; - info!("Sending info messages now"); - // Send a few test messages per potential sensor node - for plant in 0..PLANT_COUNT { - for sensor in [Sensor::A, Sensor::B] { - let detect = if sensor == Sensor::A { - request.plant[plant].sensor_a - } else { - request.plant[plant].sensor_b - }; - if !detect { - continue; - } - let target = StandardId::new(plant_id( - IDENTIFY_CMD_OFFSET, - sensor.into(), - (plant + 1) as u16, - )) - .context(">> Could not create address for sensor! (plant: {}) <<")?; - let can_buffer = [0_u8; 0]; - info!( - "Sending test message to plant {} sensor {sensor:?} with id {}", - plant + 1, - target.as_raw() - ); - if let Some(frame) = EspTwaiFrame::new(target, &can_buffer) { - // Try a few times; we intentionally ignore rx here and rely on stub logic - let resu = twai - .transmit_async(&frame) - .with_timeout(Duration::from_millis(3000)) - .await; - match resu { - Ok(_) => {} - Err(err) => { - info!( - "Error sending test message to plant {} sensor {sensor:?}: {err:?}", - plant + 1 - ); - } - } - } else { - info!("Error building CAN frame"); - } - } - } - - let mut moistures = Moistures::default(); - let _ = wait_for_can_measurements(&mut twai, &mut moistures) - .with_timeout(Duration::from_millis(3000)) - .await; - - let config = twai.stop().into_blocking(); - self.twai_config.replace(config); - - self.can_power.set_low(); - - let result = moistures.into(); - - info!("Autodetection result: {result:?}"); - Ok(result) - } } async fn wait_for_can_measurements( diff --git a/Software/MainBoard/rust/src/main.rs b/Software/MainBoard/rust/src/main.rs index 8704770..01f0031 100644 --- a/Software/MainBoard/rust/src/main.rs +++ b/Software/MainBoard/rust/src/main.rs @@ -509,11 +509,6 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { .unwrap_or(BatteryState::Unknown); info!("Battery state is {battery_state:?}"); - let state_of_charge = match &battery_state { - BatteryState::Unknown => 0, - BatteryState::Info(data) => data.state_of_charge, - }; - let mut light_state = LightState { enabled: board.board_hal.get_config().night_lamp.enabled, ..Default::default() @@ -530,13 +525,22 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { board.board_hal.get_config().night_lamp.night_lamp_hour_end, ); - if state_of_charge < board.board_hal.get_config().night_lamp.low_soc_cutoff { - board.board_hal.get_esp().set_low_voltage_in_cycle(); - info!("Set low voltage in cycle"); - } else if state_of_charge > board.board_hal.get_config().night_lamp.low_soc_restore { - board.board_hal.get_esp().clear_low_voltage_in_cycle(); - info!("Clear low voltage in cycle"); + match battery_state { + BatteryState::Unknown => { + light_state.battery_low = false; + } + BatteryState::Info(data) => { + if data.state_of_charge < board.board_hal.get_config().night_lamp.low_soc_cutoff { + board.board_hal.get_esp().set_low_voltage_in_cycle(); + info!("Set low voltage in cycle"); + } + if data.state_of_charge > board.board_hal.get_config().night_lamp.low_soc_restore { + board.board_hal.get_esp().clear_low_voltage_in_cycle(); + info!("Clear low voltage in cycle"); + } + } } + light_state.battery_low = board.board_hal.get_esp().low_voltage_in_cycle(); if !light_state.out_of_work_hour { @@ -583,7 +587,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> { let deep_sleep_duration_minutes: u32 = // if battery soc is unknown assume battery has enough change - if state_of_charge < 10 && !matches!(battery_state, BatteryState::Unknown) { + if matches!(battery_state, BatteryState::Info(data) if data.state_of_charge < 10) { let _ = board .board_hal .get_esp() @@ -1002,7 +1006,64 @@ async fn wait_infinity( let mut pattern_step = 0; let serial_config_receive = AtomicBool::new(false); let mut suppress_further_mppt_error = false; + + // Long-press exit (for webserver config modes): hold boot button for 5 seconds. + let mut exit_hold_started: Option = None; + let exit_hold_duration = Duration::from_secs(5); + let mut exit_hold_blink = false; loop { + // While in config webserver mode, allow exiting via long-press. + if matches!(wait_type, WaitType::MissingConfig | WaitType::ConfigButton) { + let mut board = BOARD_ACCESS.get().await.lock().await; + let pressed = board.board_hal.get_esp().mode_override_pressed(); + + match (pressed, exit_hold_started) { + (true, None) => { + exit_hold_started = Some(Instant::now()); + PROGRESS_ACTIVE.store(true, Ordering::Relaxed); + } + (false, Some(_)) => { + exit_hold_started = None; + // Clear any interim hold display. + board.board_hal.clear_progress().await; + } + _ => {} + } + + if let Some(started) = exit_hold_started { + let elapsed = Instant::now() - started; + // Visible countdown: fill LEDs progressively during the hold. + // Also toggle general fault LED to match the "enter config" visibility. + exit_hold_blink = !exit_hold_blink; + + let progress = core::cmp::min(elapsed, exit_hold_duration); + let lit = ((progress.as_millis() as u64 * 8) + / exit_hold_duration.as_millis() as u64) + .saturating_add(1) + .min(8) as usize; + + for i in 0..8 { + let _ = board.board_hal.fault(i, i < lit).await; + } + board.board_hal.general_fault(exit_hold_blink).await; + + if elapsed >= exit_hold_duration { + info!("Exiting config mode due to 5s button hold"); + board.board_hal.get_esp().set_restart_to_conf(false); + // ensure clean http answer / visible confirmation + Timer::after_millis(500).await; + board.board_hal.deep_sleep(0).await; + } + + // Short tick while holding so the pattern updates smoothly. + drop(board); + Timer::after_millis(100).await; + continue; + } + // Release lock and continue with normal wait blinking. + drop(board); + } + { let mut board = BOARD_ACCESS.get().await.lock().await; match update_charge_indicator(&mut board).await { @@ -1145,7 +1206,6 @@ async fn main(spawner: Spawner) -> ! { } } println!("Hal init done, starting logic"); - panic!("test"); match safe_main(spawner).await { // this should not get triggered, safe_main should not return but go into deep sleep or reboot Ok(_) => { diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index f3dcf9f..e296b17 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -191,36 +191,72 @@ export class Controller { } uploadNewFirmware(file: File) { - let current = 0; - let max = 100; - controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") - const ajax = new XMLHttpRequest(); - ajax.upload.addEventListener("progress", event => { - current = event.loaded / 1000; - max = event.total / 1000; + const reader = new FileReader(); + reader.onload = () => { + const arrayBuffer = reader.result as ArrayBuffer; + const data = new Uint8Array(arrayBuffer); + const crc = this.crc32(data); + const size = data.length; + + console.log("Uploading new firmware with size " + size + " and crc " + crc + "") + + const payload = new Uint8Array(size + 8); + const view = new DataView(payload.buffer); + view.setUint32(0, size, true); + view.setUint32(4, crc, true); + payload.set(data, 8); + + let current = 0; + let max = 100; controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") - }, 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}`); + const ajax = new XMLHttpRequest(); + ajax.upload.addEventListener("progress", event => { + current = event.loaded / 1000; + max = event.total / 1000; + controller.progressview.addProgress("ota_upload", (current / max) * 100, "Uploading firmeware (" + current + "/" + max + ")") + }, 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", () => { + controller.progressview.removeProgress("ota_upload") + toast.error("OTA upload failed due to a network error."); + }, false); + ajax.addEventListener("abort", () => { + controller.progressview.removeProgress("ota_upload") + toast.error("OTA upload was aborted."); + }, false); + ajax.open("POST", PUBLIC_URL + "/ota"); + ajax.send(payload); + }; + reader.onerror = () => { + toast.error("Error reading firmware file."); + }; + reader.readAsArrayBuffer(file); + } + + private crc32(data: Uint8Array): number { + let crc = 0xFFFFFFFF; + for (let i = 0; i < data.length; i++) { + let byte = data[i]; + crc ^= byte; + for (let j = 0; j < 8; j++) { + if (crc & 1) { + crc = (crc >>> 1) ^ 0xEDB88320; + } else { + crc = crc >>> 1; + } } - }, false); - ajax.addEventListener("error", () => { - controller.progressview.removeProgress("ota_upload") - toast.error("OTA upload failed due to a network error."); - }, false); - ajax.addEventListener("abort", () => { - controller.progressview.removeProgress("ota_upload") - toast.error("OTA upload was aborted."); - }, false); - ajax.open("POST", PUBLIC_URL + "/ota"); - ajax.send(file); + } + return (crc ^ 0xFFFFFFFF) >>> 0; } async version(): Promise { @@ -264,12 +300,14 @@ export class Controller { method: "POST", body: json, }) - .then(response => response.text()) - .then(text => statusCallback(text)) - .then( _ => { - controller.progressview.removeProgress("set_config"); - setTimeout(() => { controller.downloadConfig() }, 250) - }) + .then(response => response.text()) + .then(text => statusCallback(text)) + .then(_ => { + controller.progressview.removeProgress("set_config"); + setTimeout(() => { + controller.downloadConfig() + }, 250) + }) } async backupConfig(json: string): Promise { @@ -290,7 +328,6 @@ export class Controller { method: "POST", body: pretty }).then( - _ => controller.progressview.removeProgress("write_rtc") ) } diff --git a/Software/MainBoard/rust/src_webpack/src/ota.html b/Software/MainBoard/rust/src_webpack/src/ota.html index 0d18370..318e1cf 100644 --- a/Software/MainBoard/rust/src_webpack/src/ota.html +++ b/Software/MainBoard/rust/src_webpack/src/ota.html @@ -35,12 +35,8 @@
- State0: - -
-
- State1: - + State: +
diff --git a/website/themes/blowfish b/website/themes/blowfish index f9eb1d4..26d1205 160000 --- a/website/themes/blowfish +++ b/website/themes/blowfish @@ -1 +1 @@ -Subproject commit f9eb1d4e811d6da744848c35fb842cf386f6df39 +Subproject commit 26d1205439b460bee960fd4c29f3c5c20948875f