use crate::webserver::webserver::httpd; use anyhow::bail; use chrono::{DateTime, Datelike, Timelike, Utc}; use chrono_tz::Tz; use chrono_tz::Tz::UTC; use esp_idf_hal::delay::Delay; use esp_idf_sys::{ esp_ota_get_app_partition_count, esp_ota_get_running_partition, esp_ota_get_state_partition, esp_ota_img_states_t, esp_ota_img_states_t_ESP_OTA_IMG_ABORTED, esp_ota_img_states_t_ESP_OTA_IMG_INVALID, esp_ota_img_states_t_ESP_OTA_IMG_NEW, esp_ota_img_states_t_ESP_OTA_IMG_PENDING_VERIFY, esp_ota_img_states_t_ESP_OTA_IMG_UNDEFINED, esp_ota_img_states_t_ESP_OTA_IMG_VALID, vTaskDelay, }; use esp_ota::{mark_app_valid, rollback_and_reboot}; use hal::battery::BatteryState; use log::{log, LogMessage}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::sync::MutexGuard; use std::{ fmt::Display, sync::{atomic::AtomicBool, Arc, Mutex}, }; mod config; mod hal; mod log; mod plant_state; mod tank; use crate::config::BoardVersion::INITIAL; use crate::hal::battery::BatteryInteraction; use crate::hal::{BoardInteraction, PlantHal, HAL, PLANT_COUNT}; use plant_state::PlantState; use tank::*; pub static BOARD_ACCESS: Lazy> = Lazy::new(|| PlantHal::create().unwrap()); pub static STAY_ALIVE: Lazy = Lazy::new(|| AtomicBool::new(false)); mod webserver { pub mod webserver; } #[derive(Serialize, Deserialize, Debug, PartialEq)] enum WaitType { MissingConfig, ConfigButton, MqttConfig, } impl WaitType { fn blink_pattern(&self) -> u32 { match self { WaitType::MissingConfig => 500_u32, WaitType::ConfigButton => 100_u32, WaitType::MqttConfig => 200_u32, } } } #[derive(Serialize, Deserialize, Debug, PartialEq, Default)] /// Light State tracking data for mqtt struct LightState { /// is enabled in config enabled: bool, /// led is on active: bool, /// led should not be on at this time of day out_of_work_hour: bool, /// the battery is low so do not use led battery_low: bool, /// the sun is up is_day: bool, } #[derive(Serialize, Deserialize, Debug, PartialEq, Default)] ///mqtt stuct to track pump activities struct PumpInfo { enabled: bool, pump_ineffective: bool, } #[derive(Serialize, Deserialize, Debug, PartialEq)] /// humidity sensor error enum SensorError { Unknown, ShortCircuit { hz: f32, max: f32 }, OpenCircuit { hz: f32, min: f32 }, } #[derive(Serialize, Debug, PartialEq)] enum SntpMode { OFFLINE, SYNC { current: DateTime }, } #[derive(Serialize, Debug, PartialEq)] enum NetworkMode { WIFI { sntp: SntpMode, mqtt: bool, ip_address: String, }, OFFLINE, } fn safe_main() -> anyhow::Result<()> { // It is necessary to call this function once. Otherwise, some patches to the runtime // implemented by esp-idf-sys might not link properly. See https://github.com/esp-rs/esp-idf-template/issues/71 esp_idf_svc::sys::link_patches(); // Bind the log crate to the ESP Logging facilities esp_idf_svc::log::EspLogger::initialize_default(); if esp_idf_sys::CONFIG_MAIN_TASK_STACK_SIZE < 25000 { bail!( "stack too small: {} bail!", esp_idf_sys::CONFIG_MAIN_TASK_STACK_SIZE ) } println!("Startup Rust"); let mut to_config = false; let version = get_version(); println!( "Version using git has {} build on {}", version.git_hash, version.build_time ); let count = unsafe { esp_ota_get_app_partition_count() }; println!("Partition count is {}", count); let mut ota_state: esp_ota_img_states_t = 0; let running_partition = unsafe { esp_ota_get_running_partition() }; let address = unsafe { (*running_partition).address }; println!("Partition address is {}", address); let ota_state_string = unsafe { esp_ota_get_state_partition(running_partition, &mut ota_state); if ota_state == esp_ota_img_states_t_ESP_OTA_IMG_NEW { "ESP_OTA_IMG_NEW" } else if ota_state == esp_ota_img_states_t_ESP_OTA_IMG_PENDING_VERIFY { "ESP_OTA_IMG_PENDING_VERIFY" } else if ota_state == esp_ota_img_states_t_ESP_OTA_IMG_VALID { "ESP_OTA_IMG_VALID" } else if ota_state == esp_ota_img_states_t_ESP_OTA_IMG_INVALID { "ESP_OTA_IMG_INVALID" } else if ota_state == esp_ota_img_states_t_ESP_OTA_IMG_ABORTED { "ESP_OTA_IMG_ABORTED" } else if ota_state == esp_ota_img_states_t_ESP_OTA_IMG_UNDEFINED { "ESP_OTA_IMG_UNDEFINED" } else { &format!("unknown {ota_state}") } }; log(LogMessage::PartitionState, 0, 0, "", ota_state_string); let mut board = BOARD_ACCESS .lock() .expect("Could not lock board no other lock should be able to exist during startup!"); board.board_hal.general_fault(false); let cur = board .board_hal .get_rtc_time() .or_else(|err| { println!("rtc module error: {:?}", err); board.board_hal.general_fault(true); board.board_hal.get_esp().time() }) .map_err(|err| -> Result<(), _> { bail!("time error {}", err); }) .unwrap(); //check if we know the time current > 2020 (plausibility checks, this code is newer than 2020) if cur.year() < 2020 { to_config = true; log(LogMessage::YearInplausibleForceConfig, 0, 0, "", ""); } println!("cur is {}", cur); match board .board_hal .get_battery_monitor() .average_current_milli_ampere() { Ok(charging) => { let _ = board.board_hal.set_charge_indicator(charging > 20); } Err(_) => {} } if board.board_hal.get_esp().get_restart_to_conf() { log(LogMessage::ConfigModeSoftwareOverride, 0, 0, "", ""); for _i in 0..2 { board.board_hal.general_fault(true); Delay::new_default().delay_ms(100); board.board_hal.general_fault(false); Delay::new_default().delay_ms(100); } to_config = true; board.board_hal.general_fault(true); board.board_hal.get_esp().set_restart_to_conf(false); } else if board.board_hal.get_esp().mode_override_pressed() { board.board_hal.general_fault(true); log(LogMessage::ConfigModeButtonOverride, 0, 0, "", ""); for _i in 0..5 { board.board_hal.general_fault(true); Delay::new_default().delay_ms(100); board.board_hal.general_fault(false); Delay::new_default().delay_ms(100); } if board.board_hal.get_esp().mode_override_pressed() { board.board_hal.general_fault(true); to_config = true; } else { board.board_hal.general_fault(false); } } if board.board_hal.get_config().hardware.board == INITIAL && board.board_hal.get_config().network.ssid.is_none() { let _ = board.board_hal.get_esp().wifi_ap(); drop(board); let reboot_now = Arc::new(AtomicBool::new(false)); let _webserver = httpd(reboot_now.clone()); wait_infinity(WaitType::MissingConfig, reboot_now.clone()); } println!("attempting to connect wifi"); let network_mode = if board.board_hal.get_config().network.ssid.is_some() { try_connect_wifi_sntp_mqtt(&mut board) } else { println!("No wifi configured"); NetworkMode::OFFLINE }; if matches!(network_mode, NetworkMode::OFFLINE) && to_config { println!("Could not connect to station and config mode forced, switching to ap mode!"); match board.board_hal.get_esp().wifi_ap() { Ok(_) => { println!("Started ap, continuing") } Err(err) => println!("Could not start config override ap mode due to {}", err), } } let timezone = match &board.board_hal.get_config().timezone { Some(tz_str) => tz_str.parse::().unwrap_or_else(|_| { println!("Invalid timezone '{}', falling back to UTC", tz_str); UTC }), None => UTC, // Fallback to UTC if no timezone is set }; let timezone_time = cur.with_timezone(&timezone); println!( "Running logic at utc {} and {} {}", cur, timezone.name(), timezone_time ); if let NetworkMode::WIFI { ref ip_address, .. } = network_mode { publish_firmware_info( version, address, ota_state_string, &mut board, &ip_address, timezone_time, ); publish_battery_state(&mut board); } log( LogMessage::StartupInfo, matches!(network_mode, NetworkMode::WIFI { .. }) as u32, matches!( network_mode, NetworkMode::WIFI { sntp: SntpMode::SYNC { .. }, .. } ) as u32, matches!(network_mode, NetworkMode::WIFI { mqtt: true, .. }) .to_string() .as_str(), "", ); if to_config { //check if client or ap mode and init Wi-Fi println!("executing config mode override"); //config upload will trigger reboot! drop(board); let reboot_now = Arc::new(AtomicBool::new(false)); let _webserver = httpd(reboot_now.clone()); wait_infinity(WaitType::ConfigButton, reboot_now.clone()); } else { log(LogMessage::NormalRun, 0, 0, "", ""); } let dry_run = false; let tank_state = determine_tank_state(&mut board); if tank_state.is_enabled() { if let Some(err) = tank_state.got_error(&board.board_hal.get_config().tank) { match err { TankError::SensorDisabled => { /* unreachable */ } TankError::SensorMissing(raw_value_mv) => log( LogMessage::TankSensorMissing, raw_value_mv as u32, 0, "", "", ), TankError::SensorValueError { value, min, max } => log( LogMessage::TankSensorValueRangeError, min as u32, max as u32, &format!("{}", value), "", ), TankError::BoardError(err) => { log(LogMessage::TankSensorBoardError, 0, 0, "", &err.to_string()) } } // disabled cannot trigger this because of wrapping if is_enabled board.board_hal.general_fault(true); } else if tank_state .warn_level(&board.board_hal.get_config().tank) .is_ok_and(|warn| warn) { log(LogMessage::TankWaterLevelLow, 0, 0, "", ""); board.board_hal.general_fault(true); } } let mut water_frozen = false; let water_temp = obtain_tank_temperature(&mut board); if let Ok(res) = water_temp { if res < WATER_FROZEN_THRESH { water_frozen = true; } } publish_tank_state(&mut board, &tank_state, &water_temp); let plantstate: [PlantState; PLANT_COUNT] = core::array::from_fn(|i| PlantState::read_hardware_state(i, &mut board)); publish_plant_states(&mut board, &timezone_time, &plantstate); let pump_required = plantstate .iter() .zip(&board.board_hal.get_config().plants) .any(|(it, conf)| it.needs_to_be_watered(conf, &timezone_time)) && !water_frozen; if pump_required { log(LogMessage::EnableMain, dry_run as u32, 0, "", ""); for (plant_id, (state, plant_config)) in plantstate .iter() .zip(&board.board_hal.get_config().plants.clone()) .enumerate() { if state.needs_to_be_watered(plant_config, &timezone_time) { let pump_count = board.board_hal.get_esp().consecutive_pump_count(plant_id) + 1; board .board_hal .get_esp() .store_consecutive_pump_count(plant_id, pump_count); let pump_ineffective = pump_count > plant_config.max_consecutive_pump_count as u32; if pump_ineffective { log( LogMessage::ConsecutivePumpCountLimit, pump_count, plant_config.max_consecutive_pump_count as u32, &(plant_id + 1).to_string(), "", ); board.board_hal.fault(plant_id, true)?; } log( LogMessage::PumpPlant, (plant_id + 1) as u32, plant_config.pump_time_s as u32, &dry_run.to_string(), "", ); board .board_hal .get_esp() .store_last_pump_time(plant_id, cur); board.board_hal.get_esp().last_pump_time(plant_id); //state.active = true; pump_info(&mut board, plant_id, true, pump_ineffective); if !dry_run { board.board_hal.pump(plant_id, true)?; Delay::new_default().delay_ms(1000 * plant_config.pump_time_s as u32); board.board_hal.pump(plant_id, false)?; } pump_info(&mut board, plant_id, false, pump_ineffective); } else if !state.pump_in_timeout(plant_config, &timezone_time) { // plant does not need to be watered and is not in timeout // -> reset consecutive pump count board .board_hal .get_esp() .store_consecutive_pump_count(plant_id, 0); } } } let is_day = board.board_hal.is_day(); let state_of_charge = board .board_hal .get_battery_monitor() .state_charge_percent() .unwrap_or(0.); /// try to load full battery state if failed the battery state is unknown let battery_state = board .board_hal .get_battery_monitor() .get_battery_state() .unwrap_or(hal::battery::BatteryState::Unknown); let mut light_state = LightState { enabled: board.board_hal.get_config().night_lamp.enabled, ..Default::default() }; if light_state.enabled { light_state.is_day = is_day; light_state.out_of_work_hour = !in_time_range( &timezone_time, board .board_hal .get_config() .night_lamp .night_lamp_hour_start, 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 .into() { board.board_hal.get_esp().set_low_voltage_in_cycle(); } else if state_of_charge > board .board_hal .get_config() .night_lamp .low_soc_restore .into() { board.board_hal.get_esp().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 { if board .board_hal .get_config() .night_lamp .night_lamp_only_when_dark { if !light_state.is_day { if light_state.battery_low { board.board_hal.light(false)?; } else { light_state.active = true; board.board_hal.light(true)?; } } } else if light_state.battery_low { board.board_hal.light(false)?; } else { light_state.active = true; board.board_hal.light(true)?; } } else { light_state.active = false; board.board_hal.light(false)?; } println!("Lightstate is {:?}", light_state); } match serde_json::to_string(&light_state) { Ok(state) => { let _ = board .board_hal .get_esp() .mqtt_publish("/light", state.as_bytes()); } Err(err) => { println!("Error publishing lightstate {}", err); } }; let deep_sleep_duration_minutes: u32 = // if battery soc is unknown assume battery has enough change if state_of_charge < 10.0 && !matches!(battery_state, BatteryState::Unknown) { let _ = board .board_hal .get_esp() .mqtt_publish("/deepsleep", "low Volt 12h".as_bytes()); 12 * 60 } else if is_day { let _ = board .board_hal .get_esp() .mqtt_publish("/deepsleep", "normal 20m".as_bytes()); 20 } else { let _ = board .board_hal .get_esp() .mqtt_publish("/deepsleep", "night 1h".as_bytes()); 60 }; let _ = board .board_hal .get_esp() .mqtt_publish("/state", "sleep".as_bytes()); //determine next event //is light out of work trigger soon? //is battery low ?? //is deep sleep mark_app_valid(); let stay_alive_mqtt = STAY_ALIVE.load(std::sync::atomic::Ordering::Relaxed); let stay_alive = stay_alive_mqtt; println!("Check stay alive, current state is {}", stay_alive); if stay_alive { println!("Go to stay alive move"); drop(board); let reboot_now = Arc::new(AtomicBool::new(false)); let _webserver = httpd(reboot_now.clone()); wait_infinity(WaitType::MqttConfig, reboot_now.clone()); } board.board_hal.get_esp().set_restart_to_conf(false); board .board_hal .deep_sleep(1000 * 1000 * 60 * deep_sleep_duration_minutes as u64); } fn obtain_tank_temperature(board: &mut MutexGuard) -> anyhow::Result { //multisample should be moved to water_temperature_c let mut attempt = 1; let water_temp: Result = loop { let temp = board.board_hal.water_temperature_c(); match &temp { Ok(res) => { println!("Water temp is {}", res); break temp; } Err(err) => { println!("Could not get water temp {} attempt {}", err, attempt) } } if attempt == 5 { break temp; } attempt += 1; }; water_temp } fn publish_tank_state( board: &mut MutexGuard, tank_state: &TankState, water_temp: &anyhow::Result, ) { match serde_json::to_string( &tank_state.as_mqtt_info(&board.board_hal.get_config().tank, water_temp), ) { Ok(state) => { let _ = board .board_hal .get_esp() .mqtt_publish("/water", state.as_bytes()); } Err(err) => { println!("Error publishing tankstate {}", err); } }; } fn publish_plant_states( board: &mut MutexGuard, timezone_time: &DateTime, plantstate: &[PlantState; 8], ) { for (plant_id, (plant_state, plant_conf)) in plantstate .iter() .zip(&board.board_hal.get_config().plants.clone()) .enumerate() { match serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, &timezone_time)) { Ok(state) => { let plant_topic = format!("/plant{}", plant_id + 1); let _ = board .board_hal .get_esp() .mqtt_publish(&plant_topic, state.as_bytes()); //reduce speed as else messages will be dropped board.board_hal.get_esp().delay.delay_ms(200); } Err(err) => { println!("Error publishing plant state {}", err); } }; } } fn publish_firmware_info( version: VersionInfo, address: u32, ota_state_string: &str, board: &mut MutexGuard, ip_address: &String, timezone_time: DateTime, ) { let _ = board .board_hal .get_esp() .mqtt_publish("/firmware/address", ip_address.as_bytes()); let _ = board .board_hal .get_esp() .mqtt_publish("/firmware/githash", version.git_hash.as_bytes()); let _ = board .board_hal .get_esp() .mqtt_publish("/firmware/buildtime", version.build_time.as_bytes()); let _ = board.board_hal.get_esp().mqtt_publish( "/firmware/last_online", timezone_time.to_rfc3339().as_bytes(), ); let _ = board .board_hal .get_esp() .mqtt_publish("/firmware/ota_state", ota_state_string.as_bytes()); let _ = board.board_hal.get_esp().mqtt_publish( "/firmware/partition_address", format!("{:#06x}", address).as_bytes(), ); let _ = board .board_hal .get_esp() .mqtt_publish("/state", "online".as_bytes()); } fn try_connect_wifi_sntp_mqtt(board: &mut MutexGuard) -> NetworkMode { let nw_conf = &board.board_hal.get_config().network.clone(); match board.board_hal.get_esp().wifi(nw_conf) { Ok(ip_info) => { let sntp_mode: SntpMode = match board.board_hal.get_esp().sntp(1000 * 10) { Ok(new_time) => { println!("Using time from sntp"); let _ = board.board_hal.set_rtc_time(&new_time); SntpMode::SYNC { current: new_time } } Err(err) => { println!("sntp error: {}", err); board.board_hal.general_fault(true); SntpMode::OFFLINE } }; let mqtt_connected = if let Some(_) = board.board_hal.get_config().network.mqtt_url { let nw_config = &board.board_hal.get_config().network.clone(); match board.board_hal.get_esp().mqtt(nw_config) { Ok(_) => { println!("Mqtt connection ready"); true } Err(err) => { println!("Could not connect mqtt due to {}", err); false } } } else { false }; NetworkMode::WIFI { sntp: sntp_mode, mqtt: mqtt_connected, ip_address: ip_info.ip.to_string(), } } Err(_) => { println!("Offline mode"); board.board_hal.general_fault(true); NetworkMode::OFFLINE } } } fn pump_info( board: &mut MutexGuard, plant_id: usize, pump_active: bool, pump_ineffective: bool, ) { let pump_info = PumpInfo { enabled: pump_active, pump_ineffective, }; let pump_topic = format!("/pump{}", plant_id + 1); match serde_json::to_string(&pump_info) { Ok(state) => { let _ = board .board_hal .get_esp() .mqtt_publish(&pump_topic, state.as_bytes()); //reduce speed as else messages will be dropped Delay::new_default().delay_ms(200); } Err(err) => { println!("Error publishing pump state {}", err); } }; } fn publish_battery_state(board: &mut MutexGuard<'_, HAL<'_>>) { let state = board.board_hal.get_battery_monitor().get_battery_state(); if let Ok(serialized_battery_state_bytes) = serde_json::to_string(&state).map(|s| s.into_bytes()) { let _ = board .board_hal .get_esp() .mqtt_publish("/battery", &serialized_battery_state_bytes); } } fn wait_infinity(wait_type: WaitType, reboot_now: Arc) -> ! { let delay = wait_type.blink_pattern(); let mut led_count = 8; let mut pattern_step = 0; loop { unsafe { let mut board = BOARD_ACCESS.lock().unwrap(); if let Ok(charging) = board .board_hal .get_battery_monitor() .average_current_milli_ampere() { let _ = board.board_hal.set_charge_indicator(charging > 20); } match wait_type { WaitType::MissingConfig => { // Keep existing behavior: circular filling pattern led_count %= 8; led_count += 1; for i in 0..8 { let _ = board.board_hal.fault(i, i < led_count); } } WaitType::ConfigButton => { // Alternating pattern: 1010 1010 -> 0101 0101 pattern_step = (pattern_step + 1) % 2; for i in 0..8 { let _ = board.board_hal.fault(i, (i + pattern_step) % 2 == 0); } } WaitType::MqttConfig => { // Moving dot pattern pattern_step = (pattern_step + 1) % 8; for i in 0..8 { let _ = board.board_hal.fault(i, i == pattern_step); } } } board.board_hal.general_fault(true); drop(board); vTaskDelay(delay); let mut board = BOARD_ACCESS.lock().unwrap(); board.board_hal.general_fault(false); // Clear all LEDs for i in 0..8 { let _ = board.board_hal.fault(i, false); } drop(board); vTaskDelay(delay); if wait_type == WaitType::MqttConfig && !STAY_ALIVE.load(std::sync::atomic::Ordering::Relaxed) { reboot_now.store(true, std::sync::atomic::Ordering::Relaxed); } if reboot_now.load(std::sync::atomic::Ordering::Relaxed) { //ensure clean http answer Delay::new_default().delay_ms(500); BOARD_ACCESS.lock().unwrap().board_hal.deep_sleep(1); } } } } fn main() { let result = safe_main(); match result { // this should not get triggered, safe_main should not return but go into deep sleep with sensbile // timeout, this is just a fallback Ok(_) => { println!("Main app finished, restarting"); BOARD_ACCESS .lock() .unwrap() .board_hal .get_esp() .set_restart_to_conf(false); BOARD_ACCESS.lock().unwrap().board_hal.deep_sleep(1); } // if safe_main exists with an error, rollback to a known good ota version Err(err) => { println!("Failed main {}", err); let _rollback_successful = rollback_and_reboot(); panic!("Failed to rollback :("); } } } fn to_string(value: anyhow::Result) -> String { match value { Ok(v) => v.to_string(), Err(err) => { format!("{:?}", err) } } } pub fn in_time_range(cur: &DateTime, start: u8, end: u8) -> bool { let curhour = cur.hour() as u8; //eg 10-14 if start < end { curhour > start && curhour < end } else { //eg 20-05 curhour > start || curhour < end } } fn get_version() -> VersionInfo { let branch = env!("VERGEN_GIT_BRANCH").to_owned(); let hash = &env!("VERGEN_GIT_SHA")[0..8]; let running_partition = unsafe { esp_ota_get_running_partition() }; let address = unsafe { (*running_partition).address }; let partition = if address > 100000 { "ota_1 @ " } else { "ota_0 @ " }; VersionInfo { git_hash: branch + "@" + hash, build_time: env!("VERGEN_BUILD_TIMESTAMP").to_owned(), partition: partition.to_owned() + &address.to_string(), } } #[derive(Serialize, Debug)] struct VersionInfo { git_hash: String, build_time: String, partition: String, }