use std::{ fmt::Display, sync::{atomic::AtomicBool, Arc, Mutex}, }; use anyhow::{bail, Result}; use chrono::{DateTime, Datelike, TimeDelta, Timelike, Utc}; use chrono_tz::{Europe::Berlin, Tz}; use config::PlantWateringMode; 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::{PlantCtrlBoard, PlantHal, PLANT_COUNT}; use serde::{Deserialize, Serialize}; use crate::{config::PlantControllerConfig, webserver::webserver::httpd}; mod config; mod log; pub mod plant_hal; mod plant_state; mod tank; pub mod util; use plant_state::{PlantInfo, PlantState}; use tank::*; const TIME_ZONE: Tz = Berlin; 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, /// battery is low so do not use led battery_low: bool, /// the sun is up is_day: bool, } #[derive(Serialize, Deserialize, Debug, PartialEq)] /// humidity sensor error enum SensorError { Unknown, ShortCircuit { hz: f32, max: f32 }, OpenCircuit { hz: f32, min: f32 }, } 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 useing 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(log::LogMessage::PartitionState, 0, 0, "", ota_state_string); let mut board: std::sync::MutexGuard<'_, PlantCtrlBoard<'_>> = BOARD_ACCESS.lock().unwrap(); board.general_fault(false); log(log::LogMessage::MountingFilesystem, 0, 0, "", ""); board.mount_file_system()?; let free_space = board.file_system_size()?; log( log::LogMessage::FilesystemMount, free_space.free_size as u32, free_space.total_size as u32, &free_space.used_size.to_string(), "", ); let mut 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 check, this code is newer than 2020) if cur.year() < 2020 { to_config = true; log(log::LogMessage::YearInplausibleForceConfig, 0, 0, "", ""); } println!("cur is {}", cur); board.update_charge_indicator(); if board.get_restart_to_conf() { log(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.is_mode_override() { board.general_fault(true); log(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.is_mode_override() { board.general_fault(true); to_config = true; } else { board.general_fault(false); } } let config: PlantControllerConfig; match board.get_config() { Ok(valid) => { config = valid; } Err(err) => { log( log::LogMessage::ConfigModeMissingConfig, 0, 0, "", &err.to_string(), ); //config upload will trigger reboot! let _ = board.wifi_ap(Option::None); drop(board); let reboot_now = Arc::new(AtomicBool::new(false)); let _webserver = httpd(reboot_now.clone()); wait_infinity(WaitType::MissingConfig, reboot_now.clone()); } } let mut wifi = false; let mut mqtt = false; let mut sntp = false; println!("attempting to connect wifi"); let mut ip_address: Option = None; if config.network.ssid.is_some() { match board.wifi( config.network.ssid.clone().unwrap(), config.network.password.clone(), 10000, ) { Ok(ip_info) => { ip_address = Some(ip_info.ip.to_string()); wifi = true; match board.sntp(1000 * 10) { Ok(new_time) => { println!("Using time from sntp"); let _ = board.set_rtc_time(&new_time); cur = new_time; sntp = true; } Err(err) => { println!("sntp error: {}", err); board.general_fault(true); } } if config.network.mqtt_url.is_some() { match board.mqtt(&config) { Ok(_) => { println!("Mqtt connection ready"); mqtt = true; } Err(err) => { println!("Could not connect mqtt due to {}", err); } } } } Err(_) => { println!("Offline mode"); board.general_fault(true); } } } else { println!("No wifi configured"); } if !wifi && 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.to_string() ), } } let timezone_time = cur.with_timezone(&TIME_ZONE); println!( "Running logic at utc {} and {} {}", cur, TIME_ZONE.name(), timezone_time ); if mqtt { let ip_string = ip_address.unwrap_or("N/A".to_owned()); let _ = board.mqtt_publish(&config, "/firmware/address", ip_string.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()); publish_battery_state(&mut board, &config); } log( log::LogMessage::StartupInfo, wifi as u32, sntp as u32, &mqtt.to_string(), "", ); if to_config { //check if client or ap mode and init wifi 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(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, "", &format!("{}", &err.to_string()), ), } // disabled can not 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; //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; }; if let Ok(res) = water_temp { if res < WATER_FROZEN_THRESH { water_frozen = true; } } 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); } }; let mut plantstate: [PlantState; PLANT_COUNT] = core::array::from_fn(|i| PlantState::read_hardware_state(i, &mut board, &config.plants[i])); let pump_required = plantstate.iter().any(|it| it.needs_to_be_watered(&config.plants[i], &timezone_time)) && !water_frozen; if pump_required { log(log::LogMessage::EnableMain, dry_run as u32, 0, "", ""); if !dry_run { board.any_pump(true)?; } for plant in 0..PLANT_COUNT { let state = &mut plantstate[plant]; if state.do_water { let plant_config = &config.plants[plant]; state.consecutive_pump_count = board.consecutive_pump_count(plant) + 1; board.store_consecutive_pump_count(plant, state.consecutive_pump_count); if state.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 { log( log::LogMessage::ConsecutivePumpCountLimit, state.consecutive_pump_count as u32, plant_config.max_consecutive_pump_count as u32, &plant.to_string(), "", ); state.not_effective = true; board.fault(plant, true); } log( log::LogMessage::PumpPlant, (plant + 1) as u32, plant_config.pump_time_s as u32, &dry_run.to_string(), "", ); board.store_last_pump_time(plant, cur); board.last_pump_time(plant); state.active = true; if !dry_run { board.pump(plant, true)?; for _ in 0..plant_config.pump_time_s { Delay::new_default().delay_ms(1000); } board.pump(plant, false)?; } } } } update_plant_state(&mut plantstate, &mut board, &config); let is_day = board.is_day(); let state_of_charge = board.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).unwrap(); } else { light_state.active = true; board.light(true).unwrap(); } } } else { if light_state.battery_low { board.light(false).unwrap(); } else { light_state.active = true; board.light(true).unwrap(); } } } else { light_state.active = false; board.light(false).unwrap(); } 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()); //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.set_restart_to_conf(false); board.deep_sleep(1000 * 1000 * 60 * deep_sleep_duration_minutes as u64); } fn publish_battery_state( board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, config: &PlantControllerConfig, ) { let bat = board.get_battery_state(); match serde_json::to_string(&bat) { Ok(state) => { let _ = board.mqtt_publish(&config, "/battery", state.as_bytes()); } Err(err) => { println!("Error publishing battery_state {}", err); } }; } fn wait_infinity(wait_type: WaitType, reboot_now: Arc) -> ! { let delay = wait_type.blink_pattern(); let mut led_count = 8; loop { // TODO implement actually different blink patterns instead of modulating blink duration if wait_type == WaitType::MissingConfig { led_count %= 8; led_count += 1; }; unsafe { BOARD_ACCESS.lock().unwrap().update_charge_indicator(); //do not trigger watchdog for i in 0..8 { BOARD_ACCESS.lock().unwrap().fault(i, i < led_count); } BOARD_ACCESS.lock().unwrap().general_fault(true); vTaskDelay(delay); BOARD_ACCESS.lock().unwrap().general_fault(false); //TODO move locking outside of loop and drop afterwards for i in 0..8 { BOARD_ACCESS.lock().unwrap().fault(i, false); } vTaskDelay(delay); if wait_type == WaitType::MqttConfig { if !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 error, rollback to known good ota version Err(err) => { println!("Failed main {}", err); let _rollback_successful = rollback_and_reboot(); panic!("Failed to rollback :("); } } } fn time_to_string_utc(value_option: Option>) -> String { let converted = value_option.and_then(|utc| Some(utc.with_timezone(&TIME_ZONE))); return time_to_string(converted); } fn time_to_string(value_option: Option>) -> String { match value_option { Some(value) => { let europe_time = value.with_timezone(&TIME_ZONE); if europe_time.year() > 2023 { return europe_time.to_rfc3339(); } else { //initial value of 0 in rtc memory return "N/A".to_owned(); } } None => return "N/A".to_owned(), }; } fn sensor_to_string(value: &Option, error: &Option, enabled: bool) -> String { if enabled { match error { Some(error) => return format!("{:?}", error), None => match value { Some(v) => return v.to_string(), None => return "Error".to_owned(), }, } } else { return "disabled".to_owned(); }; } fn to_string(value: Result) -> String { return match value { Ok(v) => v.to_string(), Err(err) => { format!("{:?}", err) } }; } fn in_time_range(cur: &DateTime, start: u8, end: u8) -> bool { let curhour = cur.hour() as u8; //eg 10-14 if start < end { return curhour > start && curhour < end; } else { //eg 20-05 return 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 @ " }; return 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, }