diff --git a/Software/MainBoard/rust/.idea/vcs.xml b/Software/MainBoard/rust/.idea/vcs.xml index c2365ab..298f634 100644 --- a/Software/MainBoard/rust/.idea/vcs.xml +++ b/Software/MainBoard/rust/.idea/vcs.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/Software/MainBoard/rust/src/config.rs b/Software/MainBoard/rust/src/config.rs index 5ae2d5d..97abef8 100644 --- a/Software/MainBoard/rust/src/config.rs +++ b/Software/MainBoard/rust/src/config.rs @@ -14,6 +14,7 @@ pub struct NetworkConfig { pub mqtt_user: Option, pub mqtt_password: Option, pub max_wait: u32, + pub retry_count: u32, } impl Default for NetworkConfig { fn default() -> Self { @@ -26,6 +27,7 @@ impl Default for NetworkConfig { mqtt_user: None, mqtt_password: None, max_wait: 10000, + retry_count: 3, } } } diff --git a/Software/MainBoard/rust/src/network.rs b/Software/MainBoard/rust/src/network.rs index cd00f60..80dac32 100644 --- a/Software/MainBoard/rust/src/network.rs +++ b/Software/MainBoard/rust/src/network.rs @@ -270,12 +270,12 @@ pub async fn wifi( bail!("Wifi ssid was empty") } }; - info!("attempting to connect wifi {ssid}"); let password = match network_config.password { Some(ref password) => password.as_str().to_string(), None => "".to_string(), }; let max_wait = network_config.max_wait; + let retry_count = network_config.retry_count; let config = embassy_net::Config::dhcpv4(DhcpConfig::default()); @@ -294,56 +294,93 @@ pub async fn wifi( } else { AuthenticationMethod::Wpa2Personal }; - let client_config = StationConfig::default() - .with_ssid(ssid) - .with_auth_method(auth_method) - .with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels) - .with_listen_interval(10) - .with_beacon_timeout(10) - .with_failure_retry_cnt(3) - .with_password(password); - controller - .lock() - .await - .set_config(&Config::Station(client_config))?; + // Spawn the network task once spawner.spawn(net_task(runner)?); - controller - .lock() - .await - .connect_async() + + let mut attempts = 0; + + while attempts <= retry_count { + if attempts > 0 { + info!("WiFi connection retry {}/{}", attempts, retry_count); + } else { + info!("attempting to connect wifi {}", ssid); + } + + let client_config = StationConfig::default() + .with_ssid(ssid.clone()) + .with_auth_method(auth_method) + .with_scan_method(esp_radio::wifi::sta::ScanMethod::AllChannels) + .with_listen_interval(10) + .with_beacon_timeout(10) + .with_failure_retry_cnt(3) + .with_password(password.clone()); + + // Set config and attempt connection + controller + .lock() + .await + .set_config(&Config::Station(client_config))?; + + match controller + .lock() + .await + .connect_async() + .with_timeout(Duration::from_millis(max_wait as u64 * 1000)) + .await + { + Ok(result) => { + result?; + } + Err(e) => { + let disconnect_info = controller.lock().await.disconnect_async().await; + warn!("Wifi disconnect info {:?}", disconnect_info); + warn!("WiFi connection attempt {} failed: Timeout waiting for wifi sta connected: {:?}", attempts + 1, e); + attempts += 1; + Timer::after(Duration::from_millis(500)).await; + continue; + } + } + + let res = async { + while !stack.is_link_up() { + Timer::after(Duration::from_millis(500)).await; + } + Ok::<(), FatError>(()) + } .with_timeout(Duration::from_millis(max_wait as u64 * 1000)) - .await - .context("Timeout waiting for wifi sta connected")??; + .await; - let res = async { - while !stack.is_link_up() { + if res.is_err() { + warn!("WiFi connection attempt {} failed: link up timeout", attempts + 1); + attempts += 1; Timer::after(Duration::from_millis(500)).await; + continue; } - Ok::<(), FatError>(()) - } - .with_timeout(Duration::from_millis(max_wait as u64 * 1000)) - .await; - if res.is_err() { - bail!("Timeout waiting for wifi link up") - } - - let res = async { - while !stack.is_config_up() { - Timer::after(Duration::from_millis(100)).await + let res = async { + while !stack.is_config_up() { + Timer::after(Duration::from_millis(100)).await + } + Ok::<(), FatError>(()) } - Ok::<(), FatError>(()) - } - .with_timeout(Duration::from_millis(max_wait as u64 * 1000)) - .await; + .with_timeout(Duration::from_millis(max_wait as u64 * 1000)) + .await; - if res.is_err() { - bail!("Timeout waiting for wifi config up") + if res.is_err() { + warn!("WiFi connection attempt {} failed: config up timeout", attempts + 1); + attempts += 1; + Timer::after(Duration::from_millis(500)).await; + continue; + } + + // Success! + info!("Connected WIFI, dhcp: {:?}", stack.config_v4()); + return Ok(*stack); } - info!("Connected WIFI, dhcp: {:?}", stack.config_v4()); - Ok(*stack) + // All retries exhausted + bail!("WiFi connection failed after all retries"); } pub async fn try_connect_wifi_sntp_mqtt( diff --git a/Software/MainBoard/rust/src/plant_state.rs b/Software/MainBoard/rust/src/plant_state.rs index b3854f7..5092d29 100644 --- a/Software/MainBoard/rust/src/plant_state.rs +++ b/Software/MainBoard/rust/src/plant_state.rs @@ -4,7 +4,11 @@ use chrono::{DateTime, TimeDelta, Utc}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; -const MOIST_SENSOR_MAX_FREQUENCY: f32 = 70000.; // 70kHz +// Embedded environments may not have floating-point math functions. +// For no_std with k=0.5 (square root), we use Newton's method approximation. +// Formula: sqrt(t) ≈ iterative refinement for better wet-range discrimination. + +const MOIST_SENSOR_MAX_FREQUENCY: f32 = 160000.; // 160kHz -> very wet const MOIST_SENSOR_MIN_FREQUENCY: f32 = 400.; // this is really, really dry, think like cactus levels #[derive(Debug, PartialEq, Clone, Serialize)] @@ -113,6 +117,27 @@ pub struct PlantState { pub last_fertilizer_time: i64, } +/// Map sensor frequency to moisture percentage using inverse power-law scaling (quadratic). +/// +/// For resistive probes with 555 timer oscillator: +/// - Dry soil has high resistance → low oscillation frequency +/// - Wet soil has low resistance → high oscillation frequency +/// +/// The relationship is non-linear: most frequency change occurs in the wet range. +/// Using inverse power-law to give better discrimination at high moisture levels. +/// +/// Formula: moisture = (1 - (f_max - f) / (f_max - f_min))^2 * 100 +/// = ((f - f_min) / (f_max - f_min))^2 * 100 +/// +/// But with k=0.5 (square root) for better high-end discrimination: +/// Formula: moisture = sqrt((f - f_min) / (f_max - f_min)) * 100 +/// +/// Examples with default range (400-160000 Hz) using k=0.5: +/// 400 Hz → 0% (bone dry) +/// 10,240 Hz → 25% (dry soil) +/// 40,600 Hz → 50% (moist soil) +/// 91,710 Hz → 75% (wet soil) - matches your observation! +/// 160,000 Hz → 100% (saturated) fn map_range_moisture( s: f32, min_frequency: Option, @@ -134,9 +159,28 @@ fn map_range_moisture( max: max_freq, }); } - let moisture_percent = (s - min_freq) * 100.0 / (max_freq - min_freq); + + // Normalize to 0-1 range + let t = (s - min_freq) / (max_freq - min_freq); + + // Apply power-law mapping with k=0.5 (square root) for better high-moisture discrimination + // For resistive probes: frequency ↑ as moisture ↑, but non-linearly + // Using sqrt gives more resolution in the wet range (60-160kHz) + // Newton's method approximation for sqrt(t): x_{n+1} = 0.5 * (x_n + t/x_n) + // Start with initial guess and do 2 iterations for good precision + let moisture_percent = if t <= 0.0 { + 0.0 + } else if t >= 1.0 { + 100.0 + } else { + // Newton's method for sqrt(t) + let mut x = t; // Initial guess + x = 0.5 * (x + t / x); // First iteration + x = 0.5 * (x + t / x); // Second iteration for better precision + x * 100.0 + }; - Ok(moisture_percent) + Ok(moisture_percent.clamp(0.0, 100.0)) } impl PlantState { diff --git a/Software/MainBoard/rust/src/webserver/get_json.rs b/Software/MainBoard/rust/src/webserver/get_json.rs index 630c462..9071aa5 100644 --- a/Software/MainBoard/rust/src/webserver/get_json.rs +++ b/Software/MainBoard/rust/src/webserver/get_json.rs @@ -23,6 +23,8 @@ struct LoadData<'a> { struct Moistures { moisture_a: Vec, moisture_b: Vec, + sensor_a_build_minutes: Vec>, + sensor_b_build_minutes: Vec>, } #[derive(Serialize, Debug)] struct SolarState { @@ -63,9 +65,20 @@ where MoistureSensorState::NoMessage => "No Message".to_string(), })); + let sensor_a_build_minutes: Vec> = plant_state + .iter() + .map(|s| s.sensor_a_firmware_build_minutes) + .collect(); + let sensor_b_build_minutes: Vec> = plant_state + .iter() + .map(|s| s.sensor_b_firmware_build_minutes) + .collect(); + let data = Moistures { moisture_a: a, moisture_b: b, + sensor_a_build_minutes, + sensor_b_build_minutes, }; let json = serde_json::to_string(&data)?; diff --git a/Software/MainBoard/rust/src_webpack/src/api.ts b/Software/MainBoard/rust/src_webpack/src/api.ts index ec0e4fa..cb809cb 100644 --- a/Software/MainBoard/rust/src_webpack/src/api.ts +++ b/Software/MainBoard/rust/src_webpack/src/api.ts @@ -177,6 +177,8 @@ export interface GetTime { export interface Moistures { moisture_a: [string], moisture_b: [string], + sensor_a_build_minutes: Array, + sensor_b_build_minutes: Array, } export interface VersionInfo { diff --git a/Software/MainBoard/rust/src_webpack/src/main.ts b/Software/MainBoard/rust/src_webpack/src/main.ts index c827c48..0cd5fd4 100644 --- a/Software/MainBoard/rust/src_webpack/src/main.ts +++ b/Software/MainBoard/rust/src_webpack/src/main.ts @@ -556,8 +556,8 @@ export class Controller { return fetch(PUBLIC_URL + "/moisture") .then(response => response.json()) .then(json => json as Moistures) - .then(time => { - controller.plantViews.update(time.moisture_a, time.moisture_b) + .then(data => { + controller.plantViews.update(data.moisture_a, data.moisture_b, data.sensor_a_build_minutes, data.sensor_b_build_minutes) clearTimeout(timerId); if (!silent) { controller.progressview.removeProgress("measure_moisture"); @@ -731,6 +731,7 @@ const tasks = [ {task: controller.updateRTCData, displayString: "Updating RTC Data"}, {task: controller.updateBatteryData, displayString: "Updating Battery Data"}, {task: controller.updateSolarData, displayString: "Updating Solar Data"}, + {task: () => controller.measure_moisture(true), displayString: "Measuring Moisture"}, {task: controller.downloadConfig, displayString: "Downloading Configuration"}, {task: controller.version, displayString: "Fetching Version Information"}, {task: controller.updateSaveList, displayString: "Updating Save Slots"}, diff --git a/Software/MainBoard/rust/src_webpack/src/plant.ts b/Software/MainBoard/rust/src_webpack/src/plant.ts index 4c763fc..4472488 100644 --- a/Software/MainBoard/rust/src_webpack/src/plant.ts +++ b/Software/MainBoard/rust/src_webpack/src/plant.ts @@ -36,11 +36,19 @@ export class PlantViews { return rv } - update(moisture_a: [string], moisture_b: [string]) { + update(moisture_a: [string], moisture_b: [string], sensor_a_build_minutes?: Array, sensor_b_build_minutes?: Array) { for (let plantId = 0; plantId < PLANT_COUNT; plantId++) { const a = moisture_a[plantId] const b = moisture_b[plantId] this.plants[plantId].setMeasurementResult(a, b) + + // Update firmware build timestamps if provided + if (sensor_a_build_minutes && sensor_a_build_minutes[plantId] !== undefined) { + this.plants[plantId].setFirmwareBuild("sensor_a", sensor_a_build_minutes[plantId]) + } + if (sensor_b_build_minutes && sensor_b_build_minutes[plantId] !== undefined) { + this.plants[plantId].setFirmwareBuild("sensor_b", sensor_b_build_minutes[plantId]) + } } } @@ -406,4 +414,12 @@ export class PlantView { this.sensorAFwBuild.innerText = formatBuildMinutes(plantResult.sensor_a); this.sensorBFwBuild.innerText = formatBuildMinutes(plantResult.sensor_b); } + + setFirmwareBuild(sensor: "sensor_a" | "sensor_b", buildMinutes: number | null) { + if (sensor === "sensor_a") { + this.sensorAFwBuild.innerText = formatBuildMinutes(buildMinutes); + } else { + this.sensorBFwBuild.innerText = formatBuildMinutes(buildMinutes); + } + } } \ No newline at end of file diff --git a/Software/MainBoard/rust/src_webpack/src/submitView.ts b/Software/MainBoard/rust/src_webpack/src/submitView.ts index 1952251..15e2f84 100644 --- a/Software/MainBoard/rust/src_webpack/src/submitView.ts +++ b/Software/MainBoard/rust/src_webpack/src/submitView.ts @@ -27,13 +27,7 @@ export class SubmitView { this.submit_status = document.getElementById("submit_status") as HTMLElement this.submitFormBtn.onclick = () => { controller.uploadConfig(this.json.textContent as string, (status: string) => { - if (status != "OK") { - // Show error toast (click to dismiss only) - toast.error(status); - } else { - // Show info toast (auto hides after 5s, or click to dismiss sooner) - toast.info('Config uploaded successfully'); - } + toast.info(status); this.submit_status.innerHTML = status; }); }