use crate::{config::PlantControllerConfig, 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 log::{log, LogMessage}; use once_cell::sync::Lazy; use plant_hal::{PlantHal, PLANT_COUNT}; use serde::{Deserialize, Serialize}; use std::sync::MutexGuard; use std::{ fmt::Display, sync::{atomic::AtomicBool, Arc, Mutex}, }; mod config; mod log; pub mod plant_hal; mod plant_state; mod tank; use crate::plant_hal::{BatteryInteraction, BoardInteraction, HAL}; use plant_state::PlantState; use tank::*; pub static BOARD_ACCESS: Lazy> = Lazy::new(|| PlantHal::create_v3().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().unwrap(); board.general_fault(false); log(LogMessage::MountingFilesystem, 0, 0, "", ""); board.mount_file_system()?; let free_space = board.file_system_size()?; log( LogMessage::FilesystemMount, free_space.free_size as u32, free_space.total_size as u32, &free_space.used_size.to_string(), "", ); let cur = board .get_rtc_time() .or_else(|err| { println!("rtc module error: {:?}", err); board.general_fault(true); board.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); board.update_charge_indicator(); if board.get_restart_to_conf() { log(LogMessage::ConfigModeSoftwareOverride, 0, 0, "", ""); for _i in 0..2 { board.general_fault(true); Delay::new_default().delay_ms(100); board.general_fault(false); Delay::new_default().delay_ms(100); } to_config = true; board.general_fault(true); board.set_restart_to_conf(false); } else if board.mode_override_pressed() { board.general_fault(true); log(LogMessage::ConfigModeButtonOverride, 0, 0, "", ""); for _i in 0..5 { board.general_fault(true); Delay::new_default().delay_ms(100); board.general_fault(false); Delay::new_default().delay_ms(100); } if board.mode_override_pressed() { board.general_fault(true); to_config = true; } else { board.general_fault(false); } } let config: PlantControllerConfig = match board.get_config() { Ok(valid) => valid, Err(err) => { log( LogMessage::ConfigModeMissingConfig, 0, 0, "", &err.to_string(), ); //config upload will trigger reboot! let _ = board.wifi_ap(None); 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 config.network.ssid.is_some() { try_connect_wifi_sntp_mqtt(&mut board, &config) } 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.wifi_ap(Some(config.network.ap_ssid.clone())) { Ok(_) => { println!("Started ap, continuing") } Err(err) => println!("Could not start config override ap mode due to {}", err), } } let timezone = match &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, &config, &ip_address, timezone_time); publish_battery_state(&mut board, &config); } 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, &config); if tank_state.is_enabled() { if let Some(err) = tank_state.got_error(&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.general_fault(true); } else if tank_state.warn_level(&config.tank).is_ok_and(|warn| warn) { log(LogMessage::TankWaterLevelLow, 0, 0, "", ""); board.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, &config, &tank_state, &water_temp); let plantstate: [PlantState; PLANT_COUNT] = core::array::from_fn(|i| PlantState::read_hardware_state(i, &mut board, &config.plants[i])); publish_plant_states(&mut board, &config, &timezone_time, &plantstate); let pump_required = plantstate .iter() .zip(&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, "", ""); if !dry_run { board.any_pump(true)?; // enables main power output, eg for a central pump with valve setup or a main water valve for the risk affine } for (plant_id, (state, plant_config)) in plantstate.iter().zip(&config.plants).enumerate() { if state.needs_to_be_watered(plant_config, &timezone_time) { let pump_count = board.consecutive_pump_count(plant_id) + 1; board.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 as u32, plant_config.max_consecutive_pump_count as u32, &(plant_id+1).to_string(), "", ); board.fault(plant_id, true); } log( LogMessage::PumpPlant, (plant_id + 1) as u32, plant_config.pump_time_s as u32, &dry_run.to_string(), "", ); board.store_last_pump_time(plant_id, cur); board.last_pump_time(plant_id); //state.active = true; pump_info(&mut board, &config, plant_id, true, pump_ineffective); if !dry_run { board.pump(plant_id, true)?; Delay::new_default().delay_ms(1000 * plant_config.pump_time_s as u32); board.pump(plant_id, false)?; } pump_info(&mut board, &config, 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.store_consecutive_pump_count(plant_id, 0); } } if !dry_run { board.any_pump(false)?; // disable main power output, eg for a central pump with valve setup or a main water valve for the risk affine } } let is_day = board.is_day(); let state_of_charge = board.battery_monitor.state_charge_percent().unwrap_or(0); let mut light_state = LightState { enabled: 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, config.night_lamp.night_lamp_hour_start, config.night_lamp.night_lamp_hour_end, ); if state_of_charge < config.night_lamp.low_soc_cutoff { board.set_low_voltage_in_cycle(); } else if state_of_charge > config.night_lamp.low_soc_restore { board.clear_low_voltage_in_cycle(); } light_state.battery_low = board.low_voltage_in_cycle(); if !light_state.out_of_work_hour { if config.night_lamp.night_lamp_only_when_dark { if !light_state.is_day { if light_state.battery_low { board.light(false)?; } else { light_state.active = true; board.light(true)?; } } } else if light_state.battery_low { board.light(false)?; } else { light_state.active = true; board.light(true)?; } } else { light_state.active = false; board.light(false)?; } println!("Lightstate is {:?}", light_state); } match serde_json::to_string(&light_state) { Ok(state) => { let _ = board.mqtt_publish(&config, "/light", state.as_bytes()); } Err(err) => { println!("Error publishing lightstate {}", err); } }; let deep_sleep_duration_minutes: u32 = if state_of_charge < 10 { let _ = board.mqtt_publish(&config, "/deepsleep", "low Volt 12h".as_bytes()); 12 * 60 } else if is_day { let _ = board.mqtt_publish(&config, "/deepsleep", "normal 20m".as_bytes()); 20 } else { let _ = board.mqtt_publish(&config, "/deepsleep", "night 1h".as_bytes()); 60 }; let _ = board.mqtt_publish(&config, "/state", "sleep".as_bytes()); 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.set_restart_to_conf(false); board.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.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, config: &PlantControllerConfig, tank_state: &TankState, water_temp: &anyhow::Result) { match serde_json::to_string(&tank_state.as_mqtt_info(&config.tank, water_temp)) { Ok(state) => { let _ = board.mqtt_publish(&config, "/water", state.as_bytes()); } Err(err) => { println!("Error publishing tankstate {}", err); } }; } fn publish_plant_states(board: &mut MutexGuard, config: &PlantControllerConfig, timezone_time: &DateTime, plantstate: &[PlantState; 8]) { for (plant_id, (plant_state, plant_conf)) in plantstate.iter().zip(&config.plants).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.mqtt_publish(&config, &plant_topic, state.as_bytes()); //reduce speed as else messages will be dropped Delay::new_default().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, config: &PlantControllerConfig, ip_address: &String, timezone_time: DateTime) { let _ = board.mqtt_publish(&config, "/firmware/address", ip_address.as_bytes()); let _ = board.mqtt_publish(&config, "/firmware/githash", version.git_hash.as_bytes()); let _ = board.mqtt_publish( &config, "/firmware/buildtime", version.build_time.as_bytes(), ); let _ = board.mqtt_publish( &config, "/firmware/last_online", timezone_time.to_rfc3339().as_bytes(), ); let _ = board.mqtt_publish(&config, "/firmware/ota_state", ota_state_string.as_bytes()); let _ = board.mqtt_publish( &config, "/firmware/partition_address", format!("{:#06x}", address).as_bytes(), ); let _ = board.mqtt_publish(&config, "/state", "online".as_bytes()); } fn try_connect_wifi_sntp_mqtt(board: &mut MutexGuard, config: &PlantControllerConfig) -> NetworkMode{ match board.wifi( config.network.ssid.clone().unwrap(), config.network.password.clone(), 10000, ) { Ok(ip_info) => { let sntp_mode: SntpMode = match board.sntp(1000 * 10) { Ok(new_time) => { println!("Using time from sntp"); let _ = board.set_rtc_time(&new_time); SntpMode::SYNC {current: new_time} } Err(err) => { println!("sntp error: {}", err); board.general_fault(true); SntpMode::OFFLINE } }; let mqtt_connected = if let Some(_) = config.network.mqtt_url { match board.mqtt(&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.general_fault(true); NetworkMode::OFFLINE } } } //TODO clean this up? better state fn pump_info(board: &mut MutexGuard, config: &PlantControllerConfig, 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.mqtt_publish(config, &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<'_>>, config: &PlantControllerConfig, ) { let state = board.get_battery_state(); let _ = board.mqtt_publish(config, "/battery", state.as_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 lock = BOARD_ACCESS.lock().unwrap(); lock.update_charge_indicator(); match wait_type { WaitType::MissingConfig => { // Keep existing behavior: circular filling pattern led_count %= 8; led_count += 1; for i in 0..8 { lock.fault(i, i < led_count); } } WaitType::ConfigButton => { // Alternating pattern: 1010 1010 -> 0101 0101 pattern_step = (pattern_step + 1) % 2; for i in 0..8 { lock.fault(i, (i + pattern_step) % 2 == 0); } } WaitType::MqttConfig => { // Moving dot pattern pattern_step = (pattern_step + 1) % 8; for i in 0..8 { lock.fault(i, i == pattern_step); } } } lock.general_fault(true); drop(lock); vTaskDelay(delay); let mut lock = BOARD_ACCESS.lock().unwrap(); lock.general_fault(false); // Clear all LEDs for i in 0..8 { lock.fault(i, false); } drop(lock); 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().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().set_restart_to_conf(false); BOARD_ACCESS.lock().unwrap().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, }