use crate::config::{NetworkConfig, PlantControllerConfig}; use crate::hal::PLANT_COUNT; use crate::log::{log, LogMessage}; use crate::STAY_ALIVE; use anyhow::{anyhow, bail, Context}; use chrono::{DateTime, Utc}; use embedded_svc::ipv4::IpInfo; use embedded_svc::mqtt::client::QoS::{AtLeastOnce, ExactlyOnce}; use embedded_svc::wifi::{ AccessPointConfiguration, AccessPointInfo, AuthMethod, ClientConfiguration, Configuration, }; use esp_idf_hal::delay::Delay; use esp_idf_hal::gpio::{Level, PinDriver}; use esp_idf_svc::mqtt::client::{EspMqttClient, LwtConfiguration, MqttClientConfiguration}; use esp_idf_svc::sntp; use esp_idf_svc::sntp::SyncStatus; use esp_idf_svc::systime::EspSystemTime; use esp_idf_svc::wifi::config::{ScanConfig, ScanType}; use esp_idf_svc::wifi::EspWifi; use esp_idf_sys::{esp_spiffs_info, vTaskDelay}; use serde::Serialize; use std::ffi::CString; use std::fs; use std::fs::File; use std::path::Path; use std::result::Result::Ok as OkStd; use std::str::FromStr; use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::time::Duration; #[link_section = ".rtc.data"] static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT]; #[link_section = ".rtc.data"] static mut CONSECUTIVE_WATERING_PLANT: [u32; PLANT_COUNT] = [0; PLANT_COUNT]; #[link_section = ".rtc.data"] static mut LOW_VOLTAGE_DETECTED: bool = false; #[link_section = ".rtc.data"] static mut RESTART_TO_CONF: bool = false; #[derive(Serialize, Debug)] pub struct FileInfo { filename: String, size: usize, } #[derive(Serialize, Debug)] pub struct FileList { total: usize, used: usize, files: Vec, file_system_corrupt: Option, iter_error: Option, } pub struct FileSystemSizeInfo { pub total_size: usize, pub used_size: usize, pub free_size: usize, } pub struct MqttClient<'a> { mqtt_client: EspMqttClient<'a>, base_topic: heapless::String<64>, } pub struct Esp<'a> { pub(crate) mqtt_client: Option>, pub(crate) wifi_driver: EspWifi<'a>, pub(crate) boot_button: PinDriver<'a, esp_idf_hal::gpio::AnyIOPin, esp_idf_hal::gpio::Input>, pub(crate) delay: Delay, } impl Esp<'_> { const SPIFFS_PARTITION_NAME: &'static str = "storage"; const CONFIG_FILE: &'static str = "/spiffs/config.cfg"; const BASE_PATH: &'static str = "/spiffs"; pub(crate) fn mode_override_pressed(&mut self) -> bool { self.boot_button.get_level() == Level::Low } pub(crate) fn sntp(&mut self, max_wait_ms: u32) -> anyhow::Result> { let sntp = sntp::EspSntp::new_default()?; let mut counter = 0; while sntp.get_sync_status() != SyncStatus::Completed { self.delay.delay_ms(100); counter += 100; if counter > max_wait_ms { bail!("Reached sntp timeout, aborting") } } self.time() } pub(crate) fn time(&mut self) -> anyhow::Result> { let time = EspSystemTime {}.now().as_millis(); let smaller_time = time as i64; let local_time = DateTime::from_timestamp_millis(smaller_time) .ok_or(anyhow!("could not convert timestamp"))?; anyhow::Ok(local_time) } pub(crate) fn wifi_scan(&mut self) -> anyhow::Result> { self.wifi_driver.start_scan( &ScanConfig { scan_type: ScanType::Passive(Duration::from_secs(5)), show_hidden: false, ..Default::default() }, true, )?; anyhow::Ok(self.wifi_driver.get_scan_result()?) } pub(crate) fn last_pump_time(&self, plant: usize) -> Option> { let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant]; DateTime::from_timestamp_millis(ts) } pub(crate) fn store_last_pump_time(&mut self, plant: usize, time: DateTime) { unsafe { LAST_WATERING_TIMESTAMP[plant] = time.timestamp_millis(); } } pub(crate) fn set_low_voltage_in_cycle(&mut self) { unsafe { LOW_VOLTAGE_DETECTED = true; } } pub(crate) fn clear_low_voltage_in_cycle(&mut self) { unsafe { LOW_VOLTAGE_DETECTED = false; } } pub(crate) fn low_voltage_in_cycle(&mut self) -> bool { unsafe { LOW_VOLTAGE_DETECTED } } pub(crate) fn store_consecutive_pump_count(&mut self, plant: usize, count: u32) { unsafe { CONSECUTIVE_WATERING_PLANT[plant] = count; } } pub(crate) fn consecutive_pump_count(&mut self, plant: usize) -> u32 { unsafe { CONSECUTIVE_WATERING_PLANT[plant] } } pub(crate) fn get_restart_to_conf(&mut self) -> bool { unsafe { RESTART_TO_CONF } } pub(crate) fn set_restart_to_conf(&mut self, to_conf: bool) { unsafe { RESTART_TO_CONF = to_conf; } } pub(crate) fn wifi_ap(&mut self) -> anyhow::Result<()> { let ssid = match self.load_config() { Ok(config) => config.network.ap_ssid.clone(), Err(_) => heapless::String::from_str("PlantCtrl Emergency Mode").unwrap(), }; let apconfig = AccessPointConfiguration { ssid, auth_method: AuthMethod::None, ssid_hidden: false, ..Default::default() }; self.wifi_driver .set_configuration(&Configuration::AccessPoint(apconfig))?; self.wifi_driver.start()?; anyhow::Ok(()) } pub(crate) fn wifi(&mut self, network_config: &NetworkConfig) -> anyhow::Result { let ssid = network_config .ssid .clone() .ok_or(anyhow!("No ssid configured"))?; let password = network_config.password.clone(); let max_wait = network_config.max_wait; match password { Some(pw) => { //TODO expect error due to invalid pw or similar! //call this during configuration and check if works, revert to config mode if not self.wifi_driver.set_configuration(&Configuration::Client( ClientConfiguration { ssid, password: pw, ..Default::default() }, ))?; } None => { self.wifi_driver.set_configuration(&Configuration::Client( ClientConfiguration { ssid, auth_method: AuthMethod::None, ..Default::default() }, ))?; } } self.wifi_driver.start()?; self.wifi_driver.connect()?; let delay = Delay::new_default(); let mut counter = 0_u32; while !self.wifi_driver.is_connected()? { delay.delay_ms(250); counter += 250; if counter > max_wait { //ignore these errors, Wi-Fi will not be used this self.wifi_driver.disconnect().unwrap_or(()); self.wifi_driver.stop().unwrap_or(()); bail!("Did not manage wifi connection within timeout"); } } println!("Should be connected now, waiting for link to be up"); while !self.wifi_driver.is_up()? { delay.delay_ms(250); counter += 250; if counter > max_wait { //ignore these errors, Wi-Fi will not be used this self.wifi_driver.disconnect().unwrap_or(()); self.wifi_driver.stop().unwrap_or(()); bail!("Did not manage wifi connection within timeout"); } } //update freertos registers ;) let address = self.wifi_driver.sta_netif().get_ip_info()?; log(LogMessage::WifiInfo, 0, 0, "", &format!("{address:?}")); anyhow::Ok(address) } pub(crate) fn load_config(&mut self) -> anyhow::Result { let cfg = File::open(Self::CONFIG_FILE)?; let config: PlantControllerConfig = serde_json::from_reader(cfg)?; anyhow::Ok(config) } pub(crate) fn save_config(&mut self, config: &PlantControllerConfig) -> anyhow::Result<()> { let mut cfg = File::create(Self::CONFIG_FILE)?; serde_json::to_writer(&mut cfg, &config)?; println!("Wrote config config {:?}", config); anyhow::Ok(()) } pub(crate) fn mount_file_system(&mut self) -> anyhow::Result<()> { log(LogMessage::MountingFilesystem, 0, 0, "", ""); let base_path = CString::new("/spiffs")?; let storage = CString::new(Self::SPIFFS_PARTITION_NAME)?; let conf = esp_idf_sys::esp_vfs_spiffs_conf_t { base_path: base_path.as_ptr(), partition_label: storage.as_ptr(), max_files: 5, format_if_mount_failed: true, }; unsafe { esp_idf_sys::esp!(esp_idf_sys::esp_vfs_spiffs_register(&conf))?; } let free_space = self.file_system_size()?; log( LogMessage::FilesystemMount, free_space.free_size as u32, free_space.total_size as u32, &free_space.used_size.to_string(), "", ); anyhow::Ok(()) } fn file_system_size(&mut self) -> anyhow::Result { let storage = CString::new(Self::SPIFFS_PARTITION_NAME)?; let mut total_size = 0; let mut used_size = 0; unsafe { esp_idf_sys::esp!(esp_spiffs_info( storage.as_ptr(), &mut total_size, &mut used_size ))?; } anyhow::Ok(FileSystemSizeInfo { total_size, used_size, free_size: total_size - used_size, }) } pub(crate) fn list_files(&self) -> FileList { let storage = CString::new(Self::SPIFFS_PARTITION_NAME).unwrap(); let mut file_system_corrupt = None; let mut iter_error = None; let mut result = Vec::new(); let filepath = Path::new(Self::BASE_PATH); let read_dir = fs::read_dir(filepath); match read_dir { OkStd(read_dir) => { for item in read_dir { match item { OkStd(file) => { let f = FileInfo { filename: file.file_name().into_string().unwrap(), size: file.metadata().map(|it| it.len()).unwrap_or_default() as usize, }; result.push(f); } Err(err) => { iter_error = Some(format!("{err:?}")); break; } } } } Err(err) => { file_system_corrupt = Some(format!("{err:?}")); } } let mut total: usize = 0; let mut used: usize = 0; unsafe { esp_spiffs_info(storage.as_ptr(), &mut total, &mut used); } FileList { total, used, file_system_corrupt, files: result, iter_error, } } pub(crate) fn delete_file(&self, filename: &str) -> anyhow::Result<()> { let filepath = Path::new(Self::BASE_PATH).join(Path::new(filename)); match fs::remove_file(filepath) { OkStd(_) => anyhow::Ok(()), Err(err) => { bail!(format!("{err:?}")) } } } pub(crate) fn get_file_handle(&self, filename: &str, write: bool) -> anyhow::Result { let filepath = Path::new(Self::BASE_PATH).join(Path::new(filename)); anyhow::Ok(if write { File::create(filepath)? } else { File::open(filepath)? }) } pub(crate) fn init_rtc_deepsleep_memory(&self, init_rtc_store: bool, to_config_mode: bool) { if init_rtc_store { unsafe { LAST_WATERING_TIMESTAMP = [0; PLANT_COUNT]; CONSECUTIVE_WATERING_PLANT = [0; PLANT_COUNT]; LOW_VOLTAGE_DETECTED = false; crate::log::init(); RESTART_TO_CONF = to_config_mode; }; } else { unsafe { if to_config_mode { RESTART_TO_CONF = true; } log( LogMessage::RestartToConfig, RESTART_TO_CONF as u32, 0, "", "", ); log( LogMessage::LowVoltage, LOW_VOLTAGE_DETECTED as u32, 0, "", "", ); for i in 0..PLANT_COUNT { println!( "LAST_WATERING_TIMESTAMP[{}] = UTC {}", i, LAST_WATERING_TIMESTAMP[i] ); } for i in 0..PLANT_COUNT { println!( "CONSECUTIVE_WATERING_PLANT[{}] = {}", i, CONSECUTIVE_WATERING_PLANT[i] ); } } } } pub(crate) fn mqtt(&mut self, network_config: &NetworkConfig) -> anyhow::Result<()> { let base_topic = network_config .base_topic .as_ref() .context("missing base topic")?; if base_topic.is_empty() { bail!("Mqtt base_topic was empty") } let base_topic_copy = base_topic.clone(); let mqtt_url = network_config .mqtt_url .as_ref() .context("missing mqtt url")?; if mqtt_url.is_empty() { bail!("Mqtt url was empty") } let last_will_topic = format!("{}/state", base_topic); let mqtt_client_config = MqttClientConfiguration { lwt: Some(LwtConfiguration { topic: &last_will_topic, payload: "lost".as_bytes(), qos: AtLeastOnce, retain: true, }), client_id: Some("plantctrl"), keep_alive_interval: Some(Duration::from_secs(60 * 60 * 2)), //room for improvement ..Default::default() }; let mqtt_connected_event_received = Arc::new(AtomicBool::new(false)); let mqtt_connected_event_ok = Arc::new(AtomicBool::new(false)); let round_trip_ok = Arc::new(AtomicBool::new(false)); let round_trip_topic = format!("{}/internal/roundtrip", base_topic); let stay_alive_topic = format!("{}/stay_alive", base_topic); log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic); let mqtt_connected_event_received_copy = mqtt_connected_event_received.clone(); let mqtt_connected_event_ok_copy = mqtt_connected_event_ok.clone(); let stay_alive_topic_copy = stay_alive_topic.clone(); let round_trip_topic_copy = round_trip_topic.clone(); let round_trip_ok_copy = round_trip_ok.clone(); let client_id = mqtt_client_config.client_id.unwrap_or("not set"); log(LogMessage::MqttInfo, 0, 0, client_id, mqtt_url); let mut client = EspMqttClient::new_cb(mqtt_url, &mqtt_client_config, move |event| { let payload = event.payload(); match payload { embedded_svc::mqtt::client::EventPayload::Received { id: _, topic, data, details: _, } => { let data = String::from_utf8_lossy(data); if let Some(topic) = topic { //todo use enums if topic.eq(round_trip_topic_copy.as_str()) { round_trip_ok_copy.store(true, std::sync::atomic::Ordering::Relaxed); } else if topic.eq(stay_alive_topic_copy.as_str()) { let value = data.eq_ignore_ascii_case("true") || data.eq_ignore_ascii_case("1"); log(LogMessage::MqttStayAliveRec, 0, 0, &data, ""); STAY_ALIVE.store(value, std::sync::atomic::Ordering::Relaxed); } else { log(LogMessage::UnknownTopic, 0, 0, "", topic); } } } esp_idf_svc::mqtt::client::EventPayload::Connected(_) => { mqtt_connected_event_received_copy .store(true, std::sync::atomic::Ordering::Relaxed); mqtt_connected_event_ok_copy.store(true, std::sync::atomic::Ordering::Relaxed); println!("Mqtt connected"); } esp_idf_svc::mqtt::client::EventPayload::Disconnected => { mqtt_connected_event_received_copy .store(true, std::sync::atomic::Ordering::Relaxed); mqtt_connected_event_ok_copy.store(false, std::sync::atomic::Ordering::Relaxed); println!("Mqtt disconnected"); } esp_idf_svc::mqtt::client::EventPayload::Error(esp_error) => { println!("EspMqttError reported {:?}", esp_error); mqtt_connected_event_received_copy .store(true, std::sync::atomic::Ordering::Relaxed); mqtt_connected_event_ok_copy.store(false, std::sync::atomic::Ordering::Relaxed); println!("Mqtt error"); } esp_idf_svc::mqtt::client::EventPayload::BeforeConnect => { println!("Mqtt before connect") } esp_idf_svc::mqtt::client::EventPayload::Subscribed(_) => { println!("Mqtt subscribed") } esp_idf_svc::mqtt::client::EventPayload::Unsubscribed(_) => { println!("Mqtt unsubscribed") } esp_idf_svc::mqtt::client::EventPayload::Published(_) => { println!("Mqtt published") } esp_idf_svc::mqtt::client::EventPayload::Deleted(_) => { println!("Mqtt deleted") } } })?; let mut wait_for_connections_event = 0; while wait_for_connections_event < 100 { wait_for_connections_event += 1; match mqtt_connected_event_received.load(std::sync::atomic::Ordering::Relaxed) { true => { println!("Mqtt connection callback received, progressing"); match mqtt_connected_event_ok.load(std::sync::atomic::Ordering::Relaxed) { true => { println!("Mqtt did callback as connected, testing with roundtrip now"); //subscribe to roundtrip client.subscribe(round_trip_topic.as_str(), ExactlyOnce)?; client.subscribe(stay_alive_topic.as_str(), ExactlyOnce)?; //publish to roundtrip client.publish( round_trip_topic.as_str(), ExactlyOnce, false, "online_test".as_bytes(), )?; let mut wait_for_roundtrip = 0; while wait_for_roundtrip < 100 { wait_for_roundtrip += 1; match round_trip_ok.load(std::sync::atomic::Ordering::Relaxed) { true => { println!("Round trip registered, proceeding"); self.mqtt_client = Some(MqttClient { mqtt_client: client, base_topic: base_topic_copy, }); return anyhow::Ok(()); } false => { unsafe { vTaskDelay(10) }; } } } bail!("Mqtt did not complete roundtrip in time"); } false => { bail!("Mqtt did respond but with failure") } } } false => { unsafe { vTaskDelay(10) }; } } } bail!("Mqtt did not fire connection callback in time"); } pub(crate) fn mqtt_publish(&mut self, subtopic: &str, message: &[u8]) -> anyhow::Result<()> { if self.mqtt_client.is_none() { return anyhow::Ok(()); } if !subtopic.starts_with("/") { println!("Subtopic without / at start {}", subtopic); bail!("Subtopic without / at start {}", subtopic); } if subtopic.len() > 192 { println!("Subtopic exceeds 192 chars {}", subtopic); bail!("Subtopic exceeds 192 chars {}", subtopic); } let client = self.mqtt_client.as_mut().unwrap(); let mut full_topic: heapless::String<256> = heapless::String::new(); if full_topic.push_str(client.base_topic.as_str()).is_err() { println!("Some error assembling full_topic 1"); bail!("Some error assembling full_topic 1") }; if full_topic.push_str(subtopic).is_err() { println!("Some error assembling full_topic 2"); bail!("Some error assembling full_topic 2") }; let publish = client .mqtt_client .publish(&full_topic, ExactlyOnce, true, message); Delay::new(10).delay_ms(50); match publish { OkStd(message_id) => { println!( "Published mqtt topic {} with message {:#?} msgid is {:?}", full_topic, String::from_utf8_lossy(message), message_id ); anyhow::Ok(()) } Err(err) => { println!( "Error during mqtt send on topic {} with message {:#?} error is {:?}", full_topic, String::from_utf8_lossy(message), err ); Err(err)? } } } }