7 Commits
Author SHA1 Message Date
EmpirePhoenix 5700c3bf73 Implement pump flow monitoring and UI normalization enhancements
- Rust: Add `MIN_PUMP_FLOW_ML` constant and logging for insufficient pump flow with fault handling.
- UI: Normalize config fields to maintain consistency and prevent false dirty flags after round-trips.
- KiCad: Update board zone settings for `PlantCtrlESP32` and `MPPT`.
- IntelliJ: Add `.idea` configurations for project setup and inspections.
2026-09-04 10:30:47 +02:00
EmpirePhoenix 065623d423 Add apply_fertilizer parameter to do_secure_pump to prevent fertilizer applying on corrosion protection 2026-08-10 11:53:24 +02:00
EmpirePhoenix c112d133db Add missing Box import in battery module 2026-05-30 21:01:29 +02:00
EmpirePhoenix 95281d617f clippy fixed 2026-05-30 20:59:58 +02:00
EmpirePhoenix a2abc99275 Refactor formatting and remove unused imports in mqtt and plant_state modules 2026-05-30 20:57:39 +02:00
EmpirePhoenix 4b3c003996 Add target_pct field to PlantInfo for target moisture mode 2026-05-30 20:57:31 +02:00
EmpirePhoenix bba959f2a2 Update POST JSON size limit to use SAVEGAME_SLOT_SIZE minus header space 2026-05-30 20:55:51 +02:00
26 changed files with 292 additions and 157 deletions
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/PlantCtrl.iml" filepath="$PROJECT_DIR$/.idea/PlantCtrl.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
</component>
</project>
Generated
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/website/themes/blowfish" vcs="Git" />
</component>
</project>
+30 -2
View File
@@ -50,7 +50,23 @@
"silk_text_thickness": 0.1,
"silk_text_upright": false,
"zones": {
"min_clearance": 0.5
"border_display_style": 2,
"border_hatch_pitch": 0.5,
"corner_radius": 0.0,
"corner_smoothing": 0,
"fill_mode": 0,
"hatch_gap": 1.5,
"hatch_orientation": 0.0,
"hatch_smoothing_level": 0,
"hatch_smoothing_value": 0.1,
"hatch_thickness": 1.0,
"min_clearance": 0.5,
"min_island_area": 10.0,
"min_thickness": 0.25,
"pad_connection": 1,
"remove_islands": 0,
"thermal_relief_gap": 0.5,
"thermal_relief_spoke_width": 0.5
}
},
"diff_pair_dimensions": [
@@ -453,11 +469,14 @@
"duplicate_sheet_names": "error",
"endpoint_off_grid": "warning",
"extra_units": "error",
"field_name_whitespace": "warning",
"footprint_filter": "ignore",
"footprint_link_issues": "warning",
"four_way_junction": "ignore",
"global_label_dangling": "warning",
"ground_pin_not_ground": "warning",
"hier_label_mismatch": "error",
"isolated_pin_label": "warning",
"label_dangling": "error",
"label_multiple_wires": "warning",
"lib_symbol_issues": "warning",
@@ -480,6 +499,7 @@
"similar_power": "warning",
"simulation_model_issue": "ignore",
"single_global_label": "ignore",
"stacked_pin_name": "warning",
"unannotated": "error",
"unconnected_wire_endpoint": "warning",
"undefined_netclass": "error",
@@ -541,6 +561,10 @@
},
"schematic": {
"annotate_start_num": 0,
"annotation": {
"method": 0,
"sort_order": 0
},
"bom_export_filename": "${PROJECTNAME}.csv",
"bom_fmt_presets": [],
"bom_fmt_settings": {
@@ -710,6 +734,7 @@
"default_line_thickness": 6.0,
"default_text_size": 50.0,
"field_names": [],
"hop_over_size_choice": 0,
"intersheets_ref_own_page": false,
"intersheets_ref_prefix": "",
"intersheets_ref_short": false,
@@ -733,6 +758,7 @@
"net_format_name": "",
"page_layout_descr_file": "",
"plot_directory": "",
"reuse_designators": true,
"space_save_all_events": true,
"spice_current_sheet_as_root": false,
"spice_external_command": "spice \"%I\"",
@@ -748,7 +774,9 @@
"name": "MPPT",
"uuid": "00000000-0000-0000-0000-000000000000"
}
]
],
"used_designators": "",
"variants": []
},
"sheets": [
[
@@ -50,7 +50,23 @@
"silk_text_thickness": 0.15,
"silk_text_upright": false,
"zones": {
"min_clearance": 0.0
"border_display_style": 2,
"border_hatch_pitch": 0.5,
"corner_radius": 0.0,
"corner_smoothing": 0,
"fill_mode": 0,
"hatch_gap": 1.5,
"hatch_orientation": 0.0,
"hatch_smoothing_level": 0,
"hatch_smoothing_value": 0.1,
"hatch_thickness": 1.0,
"min_clearance": 0.0,
"min_island_area": 10.0,
"min_thickness": 0.25,
"pad_connection": 1,
"remove_islands": 0,
"thermal_relief_gap": 0.5,
"thermal_relief_spoke_width": 0.5
}
},
"diff_pair_dimensions": [
+2 -7
View File
@@ -112,19 +112,14 @@ pub struct PlantControllerConfig {
pub timezone: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub enum SensorCombineMode {
Min,
Max,
#[default]
Avg,
}
impl Default for SensorCombineMode {
fn default() -> Self {
SensorCombineMode::Avg
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(default)]
pub struct PlantConfig {
+1 -1
View File
@@ -316,7 +316,7 @@ impl From<sntpc::Error> for FatError {
impl From<BmsProtocolError> for FatError {
fn from(value: BmsProtocolError) -> Self {
match value {
BmsProtocolError::I2cCommunicationError =>FatError::String {
BmsProtocolError::I2cCommunicationError => FatError::String {
error: "I2C communication error".to_string(),
},
BmsProtocolError::ChecksumError => FatError::String {
+4 -3
View File
@@ -1,5 +1,5 @@
use crate::fat_error::{FatError, FatResult};
use crate::hal::Box;
use crate::fat_error::{FatError, FatResult};
use alloc::string::String;
use async_trait::async_trait;
use embassy_embedded_hal::shared_bus::blocking::i2c::I2cDevice;
@@ -81,7 +81,8 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
let state_of_charge =
state.remaining_capacity_mah as f32 * 100. / state.lifetime_capacity_mah as f32;
let state_of_health = state.lifetime_capacity_mah as f32 / config.capacity_mah as f32 * 100.;
let state_of_health =
state.lifetime_capacity_mah as f32 / config.capacity_mah as f32 * 100.;
Ok(BatteryState::Info(BatteryInfo {
voltage_mv: Some(state.current_mv),
@@ -91,7 +92,7 @@ impl BatteryInteraction for WCHI2CSlave<'_> {
soc_pct: Some(state_of_charge),
soh_pct: Some(state_of_health),
temperature_c: Some(state.temperature_celcius),
error: None
error: None,
}))
}
+11 -8
View File
@@ -1,5 +1,5 @@
use crate::bail;
use crate::config::{PlantControllerConfig};
use crate::config::PlantControllerConfig;
use crate::hal::savegame_manager::SavegameManager;
use crate::hal::PLANT_COUNT;
use crate::log::{log, LogMessage};
@@ -16,7 +16,6 @@ use embedded_storage::nor_flash::{check_erase, NorFlash, ReadNorFlash, RmwNorFla
use esp_bootloader_esp_idf::ota::OtaImageState::Valid;
use esp_bootloader_esp_idf::ota::{Ota, OtaImageState};
use esp_bootloader_esp_idf::partitions::{AppPartitionSubType, FlashRegion};
use serde::{Deserialize, Serialize};
use esp_hal::gpio::{Input, RtcPinWithResistors};
use esp_hal::rng::Rng;
use esp_hal::rtc_cntl::{
@@ -26,10 +25,11 @@ use esp_hal::rtc_cntl::{
use esp_hal::system::software_reset;
use esp_hal::uart::Uart;
use esp_hal::Blocking;
use esp_radio::wifi::ap::{AccessPointInfo};
use esp_radio::wifi::ap::AccessPointInfo;
use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
use esp_radio::wifi::{Interface, WifiController};
use log::{error, info};
use serde::{Deserialize, Serialize};
/// Detailed Wi-Fi scan information including signal strength
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -45,11 +45,15 @@ pub struct WifiScanDetails {
fn format_bssid(bssid: &[u8; 6]) -> String {
alloc::format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]
bssid[0],
bssid[1],
bssid[2],
bssid[3],
bssid[4],
bssid[5]
)
}
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
static mut LAST_WATERING_TIMESTAMP: [i64; PLANT_COUNT] = [0; PLANT_COUNT];
#[esp_hal::ram(unstable(rtc_fast), unstable(persistent))]
@@ -222,8 +226,8 @@ impl Esp<'_> {
ssid: ap.ssid.as_str().to_string(),
bssid: format_bssid(&ap.bssid),
rssi: ap.signal_strength as i32,
channel: ap.channel as u8,
auth_method: format!("{:?}",ap.auth_method),
channel: ap.channel,
auth_method: format!("{:?}", ap.auth_method),
})
.collect();
@@ -414,5 +418,4 @@ impl Esp<'_> {
}
}
}
}
+6 -2
View File
@@ -7,7 +7,7 @@ use esp_hal::delay::Delay;
use esp_hal::gpio::{DriveMode, Flex, Input, InputConfig, Output, OutputConfig, Pull};
use esp_hal::pcnt::channel::CtrlMode::Keep;
use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment};
use esp_hal::pcnt::unit::{Unit};
use esp_hal::pcnt::unit::Unit;
use esp_hal::peripherals::GPIO5;
use esp_hal::Async;
use log::{error, info};
@@ -145,7 +145,11 @@ impl<'a> TankSensor<'a> {
water_temp_sensor = Some(device);
break;
} else {
info!("OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})", device.address[0], ds18b20::FAMILY_CODE);
info!(
"OneWire: skipping device — not a DS18B20 (family 0x{:02X} != 0x{:02X})",
device.address[0],
ds18b20::FAMILY_CODE
);
}
}
if devices_found == 0 {
@@ -32,7 +32,8 @@ impl LiveLogBuffer {
match after {
None => (self.entries.clone(), false, next_seq),
Some(after_seq) => {
let result: Vec<_> = self.entries
let result: Vec<_> = self
.entries
.iter()
.filter(|(seq, _)| *seq > after_seq)
.cloned()
+4
View File
@@ -321,6 +321,10 @@ pub enum LogMessage {
serialize = "Trace: a: ${number_a} b: ${number_b} txt_s ${txt_short} long ${txt_long}"
)]
Trace,
#[strum(
serialize = "Pump insufficient flow, pump: ${number_a} measured ${number_b} ml, minimum is ${txt_short} ml"
)]
PumpInsufficientFlow,
#[strum(serialize = "Parsing error reading message")]
UnknownMessage,
#[strum(serialize = "Going to deep sleep for ${number_a} minutes")]
+55 -23
View File
@@ -14,9 +14,9 @@
esp_bootloader_esp_idf::esp_app_desc!();
use esp_backtrace as _;
use crate::hal::PROGRESS_ACTIVE;
use crate::config::{PlantConfig, PlantControllerConfig};
use crate::fat_error::{ContextExt, FatResult};
use crate::hal::PROGRESS_ACTIVE;
use crate::log::log;
use crate::tank::{determine_tank_state, TankError, WATER_FROZEN_THRESH};
@@ -42,8 +42,8 @@ use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Instant, Timer};
use esp_hal::rom::ets_delay_us;
use esp_hal::system::software_reset;
use esp_println::{println};
use hal::battery::{BatteryState};
use esp_println::println;
use hal::battery::BatteryState;
use log::LogMessage;
use option_lock::OptionLock;
use plant_state::PlantState;
@@ -123,8 +123,6 @@ pub struct PumpResult {
overcurrent_ma: Option<u16>,
}
async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("Startup Rust");
@@ -208,10 +206,15 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("No wifi configured, starting initial config mode");
let esp = board.board_hal.get_esp();
let ssid = esp.load_config().await
let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = esp.interface_ap.take().context("AP interface already taken")?;
let device = esp
.interface_ap
.take()
.context("AP interface already taken")?;
let stack = network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await?;
let reboot_now = Arc::new(AtomicBool::new(false));
@@ -228,7 +231,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
info!("No wifi configured");
//the current sensors require this amount to stabilize, in the case of Wi-Fi this is already handled due to connect timings;
Timer::after_millis(100).await;
network::NetworkMode::OFFLINE
network::NetworkMode::OFFLINE
};
if matches!(network_mode, network::NetworkMode::OFFLINE) && to_config {
@@ -236,14 +239,18 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let res = {
let esp = board.board_hal.get_esp();
let ssid = esp.load_config().await
let ssid = esp
.load_config()
.await
.map(|config| config.network.ap_ssid.to_string())
.unwrap_or_else(|_| String::from("PlantCtrl Emergency Mode"));
let device = match esp.interface_ap.take() {
Some(d) => d,
None => {
use crate::fat_error::FatError;
return Err(FatError::String { error: "AP interface already taken".to_string() });
return Err(FatError::String {
error: "AP interface already taken".to_string(),
});
}
};
network::wifi_ap(ssid, device, &esp.controller, &mut esp.rng, spawner).await
@@ -276,9 +283,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
if let network::NetworkMode::WIFI { ref ip_address, .. } = network_mode {
mqtt::publish_firmware_info(version, ip_address, &timezone_time.to_rfc3339()).await;
mqtt::publish_battery_state(&mut board).await.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
mqtt::publish_battery_state(&mut board)
.await
.unwrap_or_else(|e| {
error!("Error publishing battery state {e}");
});
let _ = mqtt::publish_mppt_state(&mut board).await;
let _ = mqtt::publish_wifi_scan(&mut board).await;
}
@@ -325,7 +334,9 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
if let Some(err) = tank_state.got_error(&board.board_hal.get_config().tank) {
match err {
TankError::SensorDisabled => { /* unreachable */ }
TankError::SensorMissing { raw_mv: raw_value_mv } => log(
TankError::SensorMissing {
raw_mv: raw_value_mv,
} => log(
LogMessage::TankSensorMissing,
raw_value_mv as u32,
0,
@@ -355,7 +366,11 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
}
let mut water_frozen = false;
let water_temp: FatResult<f32> = board.board_hal.get_tank_sensor().water_temperature_c().await;
let water_temp: FatResult<f32> = board
.board_hal
.get_tank_sensor()
.water_temperature_c()
.await;
if let Ok(res) = water_temp {
if res < WATER_FROZEN_THRESH {
@@ -447,7 +462,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
)
.await;
let result = do_secure_pump(&mut board, plant_id, plant_config, dry_run).await;
let result = do_secure_pump(&mut board, plant_id, plant_config, dry_run, true).await;
match result {
Ok(state) => {
overcurrent_results[plant_id] = state.overcurrent_ma;
@@ -463,6 +478,17 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
state.flow_value_ml,
)
.await;
if !dry_run && state.flow_value_ml < MIN_PUMP_FLOW_ML {
log(
LogMessage::PumpInsufficientFlow,
(plant_id + 1) as u32,
state.flow_value_ml as u32,
&(MIN_PUMP_FLOW_ML as u32).to_string(),
"",
);
board.board_hal.general_fault(true).await;
board.board_hal.fault(plant_id, true).await?;
}
}
Err(err) => {
mqtt::pump_info(
@@ -532,7 +558,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
"corrosion_prot",
"",
);
let _ = do_secure_pump(&mut board, plant_id, &plant_config, dry_run).await;
let _ = do_secure_pump(&mut board, plant_id, &plant_config, dry_run, false).await;
let _ = board.board_hal.pump(plant_id, false).await;
}
board
@@ -581,11 +607,15 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
light_state.battery_low = false;
}
BatteryState::Info(data) => {
if data.soc_pct.is_some_and(|soc| soc < board.board_hal.get_config().night_lamp.low_soc_cutoff as f32) {
if data.soc_pct.is_some_and(|soc| {
soc < board.board_hal.get_config().night_lamp.low_soc_cutoff as f32
}) {
board.board_hal.get_esp().set_low_voltage_in_cycle();
info!("Set low voltage in cycle");
}
if data.soc_pct.is_some_and(|soc| soc > board.board_hal.get_config().night_lamp.low_soc_restore as f32) {
if data.soc_pct.is_some_and(|soc| {
soc > board.board_hal.get_config().night_lamp.low_soc_restore as f32
}) {
board.board_hal.get_esp().clear_low_voltage_in_cycle();
info!("Clear low voltage in cycle");
}
@@ -681,11 +711,14 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
}
}
const MIN_PUMP_FLOW_ML: f32 = 200.0;
pub async fn do_secure_pump(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'_>>,
plant_id: usize,
plant_config: &PlantConfig,
dry_run: bool,
apply_fertilizer: bool,
) -> FatResult<PumpResult> {
const STARTUP_IGNORE_MS: u32 = 500;
const STARTUP_ABORT_CURRENT_MA: u16 = 1500;
@@ -700,8 +733,8 @@ pub async fn do_secure_pump(
let mut overcurrent_ma: Option<u16> = None;
if !dry_run {
// Run fertilizer pump first if configured and not in cooldown
if plant_config.fertilizer_s > 0 {
// Run fertilizer pump first if configured, requested, and not in cooldown
if apply_fertilizer && plant_config.fertilizer_s > 0 {
let current_time = board.board_hal.get_time().await;
let last_fertilizer = board.board_hal.get_esp().last_fertilizer_time(plant_id);
// Convert last_fertilizer from milliseconds to seconds for correct subtraction
@@ -988,8 +1021,7 @@ async fn wait_infinity(
let timezone_time = cur.with_timezone(&timezone);
mqtt::publish("/state", "config").await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339())
.await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339()).await;
last_mqtt_update = Some(now);
}
+2 -11
View File
@@ -288,11 +288,7 @@ pub async fn publish_plant_states(
Ok(())
}
pub async fn publish_firmware_info(
version: VersionInfo,
ip_address: &str,
timezone_time: &str,
) {
pub async fn publish_firmware_info(version: VersionInfo, ip_address: &str, timezone_time: &str) {
publish("/firmware/address", ip_address).await;
let version = &serde_json::to_string(&version);
match version {
@@ -384,12 +380,7 @@ pub async fn publish_mppt_state(
pub async fn publish_battery_state(
board: &mut MutexGuard<'_, CriticalSectionRawMutex, HAL<'static>>,
) -> FatResult<()> {
let telemetry = match board
.board_hal
.get_battery_monitor()
.get_state()
.await
{
let telemetry = match board.board_hal.get_battery_monitor().get_state().await {
Ok(BatteryState::Info(info)) => info,
Ok(BatteryState::Unknown) => BatteryInfo {
voltage_mv: None,
+19 -14
View File
@@ -1,13 +1,19 @@
use crate::bail;
use crate::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::{HAL};
use crate::hal::HAL;
use crate::mqtt;
use crate::util::mk_static;
use alloc::string::{String, ToString};
use alloc::sync::Arc;
use chrono::{DateTime, Utc};
use core::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4};
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
use embassy_executor::Spawner;
use embassy_net::dns::DnsQueryType;
use embassy_net::udp::{PacketMetadata, UdpSocket};
@@ -15,21 +21,15 @@ use embassy_net::{DhcpConfig, Runner, Stack, StackResources, StaticConfigV4};
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::{Mutex, MutexGuard};
use embassy_time::{Duration, Timer, WithTimeout};
use option_lock::OptionLock;
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
use esp_hal::rng::Rng;
use esp_println::println;
use esp_radio::wifi::ap::AccessPointConfig;
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{AuthenticationMethod, Config, Interface};
use log::{info, warn, error};
use log::{error, info, warn};
use option_lock::OptionLock;
use serde::Serialize;
use sntpc::{NtpContext, NtpTimestampGenerator, NtpUdpSocket, get_time};
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
const NTP_SERVER: &str = "pool.ntp.org";
@@ -225,8 +225,7 @@ pub async fn wifi_ap(
);
let stack = mk_static!(Stack, stack);
let client_config =
Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
let client_config = Config::AccessPoint(AccessPointConfig::default().with_ssid(ssid.clone()));
controller.lock().await.set_config(&client_config)?;
println!("start net task");
@@ -352,7 +351,10 @@ pub async fn wifi(
.await;
if res.is_err() {
warn!("WiFi connection attempt {} failed: link up timeout", attempts + 1);
warn!(
"WiFi connection attempt {} failed: link up timeout",
attempts + 1
);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
@@ -368,7 +370,10 @@ pub async fn wifi(
.await;
if res.is_err() {
warn!("WiFi connection attempt {} failed: config up timeout", attempts + 1);
warn!(
"WiFi connection attempt {} failed: config up timeout",
attempts + 1
);
attempts += 1;
Timer::after(Duration::from_millis(500)).await;
continue;
+36 -37
View File
@@ -1,9 +1,10 @@
use crate::config::SensorCombineMode;
use crate::hal::Moistures;
use crate::plant_state::PlantWateringMode::TargetMoisture;
use crate::{config::PlantConfig, hal::HAL, in_time_range};
use chrono::{DateTime, TimeDelta, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use crate::config::SensorCombineMode;
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
@@ -14,7 +15,7 @@ pub enum MoistureSensorError {
MissingMessage,
NotExpectedMessage { hz: f32 },
ShortCircuit { hz: f32, max: f32 },
OpenLoop { hz: f32, min: f32 }
OpenLoop { hz: f32, min: f32 },
}
#[derive(Debug, PartialEq, Serialize)]
@@ -245,11 +246,14 @@ impl PlantState {
};
// Check for sensor warning condition (expected 2 sensors, only 1 responding)
let has_a = state.sensor_a.moisture_percent().is_some() && state.sensor_a.is_err().is_none();
let has_b = state.sensor_b.moisture_percent().is_some() && state.sensor_b.is_err().is_none();
let has_a =
state.sensor_a.moisture_percent().is_some() && state.sensor_a.is_err().is_none();
let has_b =
state.sensor_b.moisture_percent().is_some() && state.sensor_b.is_err().is_none();
// Check if we expected two sensors but only got one
let has_sensor_warning = expected_a && expected_b && ((has_a && !has_b) || (!has_a && has_b));
let has_sensor_warning =
expected_a && expected_b && ((has_a && !has_b) || (!has_a && has_b));
// Set fault LED for both errors AND sensor warnings
let has_issue = state.is_err() || has_sensor_warning;
@@ -282,30 +286,20 @@ impl PlantState {
/// - Combined moisture percentage (or None if no valid readings)
/// - Tuple of errors from sensor A and B
/// - Sensor warning indicating if warning LED should be lit (MissingSecondSensor)
pub fn plant_moisture_with_warning(
&self,
plant_conf: &PlantConfig,
) -> Option<f32>
{
let moisture = match (
pub fn plant_moisture_with_warning(&self, plant_conf: &PlantConfig) -> Option<f32> {
match (
self.sensor_a.moisture_percent(),
self.sensor_b.moisture_percent(),
) {
(Some(moisture_a), Some(moisture_b)) => {
match plant_conf.sensor_combine_mode {
SensorCombineMode::Min => Some(moisture_a.min(moisture_b)),
SensorCombineMode::Max => Some(moisture_a.max(moisture_b)),
SensorCombineMode::Avg => Some((moisture_a + moisture_b) / 2.0),
}
}
(Some(moisture_a), Some(moisture_b)) => match plant_conf.sensor_combine_mode {
SensorCombineMode::Min => Some(moisture_a.min(moisture_b)),
SensorCombineMode::Max => Some(moisture_a.max(moisture_b)),
SensorCombineMode::Avg => Some((moisture_a + moisture_b) / 2.0),
},
(Some(moisture), _) => Some(moisture),
(_, Some(moisture)) => Some(moisture),
_ => None,
};
moisture
}
}
pub fn needs_to_be_watered(
@@ -342,17 +336,18 @@ impl PlantState {
}
}
pub fn to_mqtt_info(
&self,
plant_conf: &PlantConfig,
current_time: &DateTime<Tz>,
) -> PlantInfo {
pub fn to_mqtt_info(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> PlantInfo {
let moisture_pct = self.plant_moisture_with_warning(plant_conf);
PlantInfo {
moisture_pct,
sensor_a: Self::sensor_to_telemetry(&self.sensor_a),
sensor_b: Self::sensor_to_telemetry(&self.sensor_b),
mode: plant_conf.mode,
target_pct: if plant_conf.mode == TargetMoisture {
Some(plant_conf.target_moisture as f32)
} else {
None
},
do_water: self.needs_to_be_watered(plant_conf, current_time),
dry: if let Some(moisture_percent) = moisture_pct {
moisture_percent < plant_conf.target_moisture.into()
@@ -385,7 +380,9 @@ impl PlantState {
} else {
None
},
last_fertilizer: self.last_fertilizer_time.map(|t| t.with_timezone(&current_time.timezone())),
last_fertilizer: self
.last_fertilizer_time
.map(|t| t.with_timezone(&current_time.timezone())),
next_fertilizer: if matches!(
plant_conf.mode,
PlantWateringMode::TimerOnly
@@ -396,7 +393,9 @@ impl PlantState {
// Convert to Tz for calculation, then back
let tz_last_fert = last_fert.with_timezone(&current_time.timezone());
tz_last_fert
.checked_add_signed(TimeDelta::minutes(plant_conf.fertilizer_cooldown_min.into()))
.checked_add_signed(TimeDelta::minutes(
plant_conf.fertilizer_cooldown_min.into(),
))
.map(|t| t.with_timezone(&current_time.timezone()))
})
} else {
@@ -409,13 +408,11 @@ impl PlantState {
fn sensor_to_telemetry(sensor: &MoistureSensorState) -> SensorTelemetry {
match sensor {
MoistureSensorState::NoMessage => {
SensorTelemetry {
moisture_pct: None,
raw_hz: None,
error: None
}
}
MoistureSensorState::NoMessage => SensorTelemetry {
moisture_pct: None,
raw_hz: None,
error: None,
},
MoistureSensorState::MoistureValue {
hz,
moisture_percent,
@@ -438,6 +435,8 @@ impl PlantState {
pub struct PlantInfo {
/// combined plant moisture from available sensors
moisture_pct: Option<f32>,
/// moisture target, if in targetmode
target_pct: Option<f32>,
/// state of humidity sensor on bank a
sensor_a: SensorTelemetry,
/// state of humidity sensor on bank b
+10 -6
View File
@@ -26,7 +26,9 @@ pub enum TankState {
fn raw_voltage_to_divider_percent(raw_value_mv: f32) -> Result<f32, TankError> {
if raw_value_mv > OPEN_TANK_VOLTAGE {
return Err(TankError::SensorMissing { raw_mv: raw_value_mv });
return Err(TankError::SensorMissing {
raw_mv: raw_value_mv,
});
}
let r2 = raw_value_mv * 50.0 / (3.3 - raw_value_mv);
@@ -161,12 +163,14 @@ pub async fn determine_tank_state(
if board.board_hal.get_config().tank.tank_sensor_enabled {
match board
.board_hal
.get_tank_sensor().tank_sensor_voltage().await
.get_tank_sensor()
.tank_sensor_voltage()
.await
{
Ok(raw_sensor_value_mv) => {
TankState::Present(raw_sensor_value_mv)
},
Err(err) => TankState::Error(TankError::BoardError { message: err.to_string() }),
Ok(raw_sensor_value_mv) => TankState::Present(raw_sensor_value_mv),
Err(err) => TankState::Error(TankError::BoardError {
message: err.to_string(),
}),
}
} else {
TankState::Disabled
@@ -93,7 +93,11 @@ where
{
let mut board = BOARD_ACCESS.get().await.lock().await;
let tank_state = determine_tank_state(&mut board).await;
let water_temp: FatResult<f32> = board.board_hal.get_tank_sensor().water_temperature_c().await;
let water_temp: FatResult<f32> = board
.board_hal
.get_tank_sensor()
.water_temperature_c()
.await;
Ok(Some(serde_json::to_string(&tank_state.as_mqtt_info(
&board.board_hal.get_config().tank,
&water_temp,
+3 -3
View File
@@ -10,9 +10,9 @@ mod post_json;
use crate::fat_error::{FatError, FatResult};
use crate::webserver::backup_manager::{backup_config, backup_info, get_backup_config};
use crate::webserver::get_json::{
delete_save, get_battery_state, get_config, get_live_moisture, get_log_localization_config,
get_firmware_info_web, get_solar_state, get_time, get_timezones, list_saves, tank_info,
get_wifi_details,
delete_save, get_battery_state, get_config, get_firmware_info_web, get_live_moisture,
get_log_localization_config, get_solar_state, get_time, get_timezones, get_wifi_details,
list_saves, tank_info,
};
use crate::webserver::get_log::{get_live_log, get_log};
use crate::webserver::get_static::{serve_bundle, serve_favicon, serve_index};
@@ -1,5 +1,6 @@
use crate::config::PlantControllerConfig;
use crate::fat_error::FatResult;
use crate::hal::savegame_manager::SAVEGAME_SLOT_SIZE;
use crate::hal::DetectionRequest;
use crate::webserver::read_up_to_bytes_from_request;
use crate::{do_secure_pump, BOARD_ACCESS};
@@ -82,7 +83,7 @@ where
let mut board = BOARD_ACCESS.get().await.lock().await;
let config = &board.board_hal.get_config().plants[pump_test.pump].clone();
let pump_result = do_secure_pump(&mut board, pump_test.pump, config, false).await;
let pump_result = do_secure_pump(&mut board, pump_test.pump, config, false, false).await;
//ensure it is disabled before unwrapping
board.board_hal.pump(pump_test.pump, false).await?;
@@ -135,7 +136,8 @@ pub(crate) async fn set_config<T, const N: usize>(
where
T: Read + Write,
{
let all = read_up_to_bytes_from_request(request, Some(4096)).await?;
//accept nearly full slotsize leave some space for header
let all = read_up_to_bytes_from_request(request, Some(SAVEGAME_SLOT_SIZE - 512)).await?;
let length = all.len();
let config: PlantControllerConfig = serde_json::from_slice(&all)?;
@@ -36,10 +36,10 @@ export interface BackupHeader {
export interface NetworkConfig {
ap_ssid: string,
ssid: string,
password: string,
mqtt_url: string,
base_topic: string,
ssid: string | null,
password: string | null,
mqtt_url: string | null,
base_topic: string | null,
mqtt_user: string | null,
mqtt_password: string | null,
max_wait: number
@@ -246,8 +246,11 @@ export class Controller {
const response = await fetch(PUBLIC_URL + "/get_config");
const loaded = await response.json();
const currentConfig = loaded as PlantControllerConfig;
controller.setInitialConfig(currentConfig);
controller.setConfig(currentConfig);
// Store the config after the UI round-trip so null fields that the UI
// normalises (e.g. null → "" → null, null timezone → "UTC") are
// represented consistently and don't trigger a false dirty flag.
controller.setInitialConfig(controller.getConfig());
//sync json view initially
controller.configChanged();
controller.progressview.removeProgress("get_config");
@@ -102,10 +102,10 @@ export class NetworkConfigView {
setConfig(network: NetworkConfig) {
this.ap_ssid.value = network.ap_ssid;
this.ssid.value = network.ssid;
this.password.value = network.password;
this.mqtt_url.value = network.mqtt_url;
this.base_topic.value = network.base_topic;
this.ssid.value = network.ssid ?? "";
this.password.value = network.password ?? "";
this.mqtt_url.value = network.mqtt_url ?? "";
this.base_topic.value = network.base_topic ?? "";
this.mqtt_user.value = network.mqtt_user ?? "";
this.mqtt_password.value = network.mqtt_password ?? "";
this.max_wait.value = network.max_wait.toString();
@@ -115,12 +115,12 @@ export class NetworkConfigView {
return {
max_wait: +this.max_wait.value,
ap_ssid: this.ap_ssid.value,
ssid: this.ssid.value ?? null,
password: this.password.value ?? null,
mqtt_url: this.mqtt_url.value ?? null,
mqtt_user: this.mqtt_user.value ? this.mqtt_user.value : null,
mqtt_password: this.mqtt_password.value ? this.mqtt_password.value : null,
base_topic: this.base_topic.value ?? null
ssid: this.ssid.value || null,
password: this.password.value || null,
mqtt_url: this.mqtt_url.value || null,
mqtt_user: this.mqtt_user.value || null,
mqtt_password: this.mqtt_password.value || null,
base_topic: this.base_topic.value || null
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>