Merge branch 'develop' of ssh://git.mannheim.ccc.de:1337/C3MA/PlantCtrl into develop

This commit is contained in:
2026-03-18 01:25:29 +01:00
10 changed files with 225 additions and 129 deletions

Binary file not shown.

View File

@@ -0,0 +1 @@
cargo espflash erase-parts otadata --partition-table partitions.csv

Binary file not shown.

View File

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

View File

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

View File

@@ -357,6 +357,7 @@ impl<'a> BoardInteraction<'a> for V4<'a> {
}
async fn measure_moisture_hz(&mut self) -> FatResult<Moistures> {
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<Detection> {
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<Detection> {
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(

View File

@@ -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<Instant> = 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(_) => {

View File

@@ -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<void> {
@@ -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<string> {
@@ -290,7 +328,6 @@ export class Controller {
method: "POST",
body: pretty
}).then(
_ => controller.progressview.removeProgress("write_rtc")
)
}

View File

@@ -35,12 +35,8 @@
<span class="otavalue" id="firmware_partition"></span>
</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>
<span class="otakey">State:</span>
<span class="otavalue" id="firmware_state"></span>
</div>
<div class="flexcontainer">