14 Commits

Author SHA1 Message Date
2cfb2607a9 refactor: move Solar struct to mqtt module 2026-05-10 02:38:50 +02:00
271c1a1383 refactor: move PumpInfo struct to mqtt module 2026-05-10 02:38:43 +02:00
a02b84d732 refactor: create mqtt module with core MQTT statics and tasks 2026-05-10 02:34:16 +02:00
b0f8bcc9da Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	Software/MainBoard/rust/src/hal/water.rs
2026-05-06 09:29:25 +02:00
103859120c Add initial TODO file with pending tasks for One Wire, Flow Sensor, and PlantProfiles implementation 2026-05-06 09:27:19 +02:00
403517fdb4 Suppress EMI noise on water flow sensor by filtering short pulses 2026-05-06 09:26:14 +02:00
11eb8713bf more startup debugging 2026-05-05 18:04:00 +02:00
d903c2bf52 Refactor flow meter logic: replace global mutex with per-instance flow_unit and use critical-section for thread safety. 2026-05-05 16:27:21 +02:00
f8f76674ce Refactor flow meter handling: switch get_flow_meter_value to get_full_flow_count, update related structs and logic to use u32 for flow values. 2026-05-05 01:14:54 +02:00
3cc5a0d2bd dependency lock upgrade 2026-05-05 00:50:18 +02:00
3be585ecbf Refactor flow meter handling with interrupt-based logic and global state
- Added `flow_interrupt_handler` for efficient interrupt processing.
- Replaced per-instance `flow_counter` with global atomic and mutex-based state (`FLOW_OVERFLOW_COUNTER`, `FLOW_UNIT`).
- Updated flow meter functions to leverage the new architecture for better modularity and thread safety.
- Switched debugging output from `println!` to `log` for improved logging consistency.
2026-05-05 00:50:18 +02:00
5b1a945ac3 Replace blocking http_server call with async task using spawner 2026-05-05 00:50:18 +02:00
f4e050d413 Add ChecksumError handling to FatError conversion 2026-05-05 00:50:18 +02:00
776db785c4 Update hardware and firmware documentation for new modules and features
- Removed outdated TODOs and legacy references in hardware documentation.
- Added details on the new CH32V203-based Sensor Module for CAN bus soil moisture sensors.
- Documented updates to the Battery Management System (CH32V203-based) replacing the older bq34z100 design.
- Refined sensor, pump, and power module descriptions with updated specifications.
- Expanded firmware documentation to include Rust-based ESP32-C6 platform details, new OTA procedure, and MQTT telemetry topics.
- Simplified toolchain setup and compilation process with updated scripts and instructions.
2026-05-05 00:50:18 +02:00
17 changed files with 707 additions and 624 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,7 @@ chrono-tz = { version = "0.10.4", default-features = false, features = ["filter-
heapless = { version = "0.7.17", features = ["serde"] } # stay in sync with mcutie version
static_cell = "2.1.1"
portable-atomic = "1.11.1"
critical-section = "1"
crc = "3.3.0"
bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics", "pod_saturating", "extern_crate_alloc"] }
deranged = "0.5.5"

View File

@@ -0,0 +1,3 @@
One Wire does not seem to work.
Flow Sensor does not seem to work.
PlantProfiles with a dry out phase needs to be implemented + Memory for this

View File

@@ -319,6 +319,9 @@ impl From<BmsProtocolError> for FatError {
BmsProtocolError::I2cCommunicationError =>FatError::String {
error: "I2C communication error".to_string(),
},
BmsProtocolError::ChecksumError => FatError::String {
error: "BMS checksum error".to_string(),
},
}
}
}

View File

@@ -39,10 +39,6 @@ use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{AuthenticationMethod, Config, Interface, WifiController};
use log::{error, info, warn};
use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
QoS, Topic,
};
use portable_atomic::AtomicBool;
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
@@ -61,11 +57,6 @@ static mut LAST_CORROSION_PROTECTION_CHECK_DAY: i8 = -1;
const NTP_SERVER: &str = "pool.ntp.org";
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false);
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new();
#[derive(Copy, Clone, Default)]
struct Timestamp {
stamp: DateTime<Utc>,
@@ -665,228 +656,6 @@ impl Esp<'_> {
}
}
pub(crate) async fn mqtt(
&mut self,
network_config: &'static NetworkConfig,
stack: Stack<'static>,
spawner: Spawner,
) -> FatResult<()> {
let base_topic = network_config
.base_topic
.as_ref()
.context("missing base topic")?;
if base_topic.is_empty() {
bail!("Mqtt base_topic was empty")
}
MQTT_BASE_TOPIC
.init(base_topic.to_string())
.map_err(|_| FatError::String {
error: "Error setting basetopic".to_string(),
})?;
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!("{base_topic}/state");
let round_trip_topic = format!("{base_topic}/internal/roundtrip");
let stay_alive_topic = format!("{base_topic}/stay_alive");
let mut builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 0> =
McutieBuilder::new(stack, "plant ctrl", mqtt_url);
if let (Some(mqtt_user), Some(mqtt_password)) = (
network_config.mqtt_user.as_ref(),
network_config.mqtt_password.as_ref(),
) {
builder = builder.with_authentication(mqtt_user, mqtt_password);
info!("With authentification");
}
let lwt = Topic::General(last_will_topic);
let lwt = mk_static!(Topic<String>, lwt);
let lwt = lwt.with_display("lost").retain(true).qos(QoS::AtLeastOnce);
builder = builder.with_last_will(lwt);
//TODO make configurable
builder = builder.with_device_id("plantctrl");
let builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 2> = builder
.with_subscriptions([
Topic::General(round_trip_topic.clone()),
Topic::General(stay_alive_topic.clone()),
]);
let keep_alive = Duration::from_secs(60 * 60 * 2).as_secs() as u16;
let (receiver, task) = builder.build(keep_alive);
spawner.spawn(mqtt_incoming_task(
receiver,
round_trip_topic.clone(),
stay_alive_topic.clone(),
)?);
spawner.spawn(mqtt_runner(task)?);
log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic);
log(LogMessage::MqttInfo, 0, 0, "", mqtt_url);
let mqtt_timeout = 15000;
let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
bail!("Timeout waiting MQTT connect event")
}
let _ = Topic::General(round_trip_topic.clone())
.with_display("online_text")
.publish()
.await;
let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
crate::hal::PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
//ensure we do not further try to publish
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
bail!("Timeout waiting MQTT roundtrip")
}
Ok(())
}
pub(crate) async fn mqtt_inner(&mut self, subtopic: &str, message: &str) -> FatResult<()> {
if !subtopic.starts_with("/") {
bail!("Subtopic without / at start {}", subtopic);
}
if subtopic.len() > 192 {
bail!("Subtopic exceeds 192 chars {}", subtopic);
}
let base_topic = MQTT_BASE_TOPIC
.try_get()
.context("missing base topic in static!")?;
let full_topic = format!("{base_topic}{subtopic}");
loop {
let result = Topic::General(full_topic.as_str())
.with_display(message)
.retain(true)
.publish()
.await;
match result {
Ok(()) => return Ok(()),
Err(err) => {
let retry = match err {
Error::IOError => false,
Error::TimedOut => true,
Error::TooLarge => false,
Error::PacketError => false,
Error::Invalid => false,
Error::Rejected => false,
};
if !retry {
bail!(
"Error during mqtt send on topic {} with message {:#?} error is {:?}",
&full_topic,
message,
err
);
}
info!(
"Retransmit for {} with message {:#?} error is {:?} retrying {}",
&full_topic, message, err, retry
);
Timer::after(Duration::from_millis(100)).await;
}
}
}
}
pub(crate) async fn mqtt_publish(&mut self, subtopic: &str, message: &str) {
let online = MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed);
if !online {
return;
}
let roundtrip_ok = MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed);
if !roundtrip_ok {
info!("MQTT roundtrip not received yet, dropping message");
return;
}
match self.mqtt_inner(subtopic, message).await {
Ok(()) => {}
Err(err) => {
info!(
"Error during mqtt send on topic {subtopic} with message {message:#?} error is {err:?}"
);
}
};
}
}
#[embassy_executor::task]
async fn mqtt_runner(
task: McutieTask<'static, String, PublishDisplay<'static, String, &'static str>, 2>,
) {
task.run().await;
}
#[embassy_executor::task]
async fn mqtt_incoming_task(
receiver: McutieReceiver,
round_trip_topic: String,
stay_alive_topic: String,
) {
loop {
let message = receiver.receive().await;
match message {
MqttMessage::Connected => {
info!("Mqtt connected");
MQTT_CONNECTED_EVENT_RECEIVED.store(true, Ordering::Relaxed);
}
MqttMessage::Publish(topic, payload) => match topic {
Topic::DeviceType(_type_topic) => {}
Topic::Device(_device_topic) => {}
Topic::General(topic) => {
let subtopic = topic.as_str();
if subtopic.eq(round_trip_topic.as_str()) {
MQTT_ROUND_TRIP_RECEIVED.store(true, Ordering::Relaxed);
} else if subtopic.eq(stay_alive_topic.as_str()) {
let value = payload.eq_ignore_ascii_case("true".as_ref())
|| payload.eq_ignore_ascii_case("1".as_ref());
let a = match value {
true => 1,
false => 0,
};
log(LogMessage::MqttStayAliveRec, a, 0, "", "");
MQTT_STAY_ALIVE.store(value, Ordering::Relaxed);
} else {
log(LogMessage::UnknownTopic, 0, 0, "", &topic);
}
}
},
MqttMessage::Disconnected => {
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
info!("Mqtt disconnected");
}
}
}
}
#[embassy_executor::task(pool_size = 2)]

View File

@@ -290,7 +290,8 @@ impl PlantHal {
error: format!("Could not init wifi: {:?}", e),
})?;
let pcnt_module = Pcnt::new(peripherals.PCNT);
let mut pcnt_module = Pcnt::new(peripherals.PCNT);
pcnt_module.set_interrupt_handler(water::flow_interrupt_handler);
let free_pins = FreePeripherals {
gpio0: peripherals.GPIO0,

View File

@@ -161,9 +161,11 @@ pub(crate) async fn create_v4(
info!("Start v4");
let mut awake = Output::new(peripherals.gpio21, Level::High, OutputConfig::default());
awake.set_high();
info!("v4: gpio21 awake ok");
let mut general_fault = Output::new(peripherals.gpio23, Level::Low, OutputConfig::default());
general_fault.set_low();
info!("v4: gpio23 general_fault ok");
let twai_config = Some(TwaiConfiguration::new(
peripherals.twai,
@@ -172,17 +174,24 @@ pub(crate) async fn create_v4(
TWAI_BAUDRATE,
TwaiMode::Normal,
));
info!("v4: twai config ok");
let extra1 = Output::new(peripherals.gpio6, Level::Low, OutputConfig::default());
info!("v4: gpio6 extra1 ok");
let extra2 = Output::new(peripherals.gpio15, Level::Low, OutputConfig::default());
info!("v4: gpio15 extra2 ok");
let one_wire_pin = Flex::new(peripherals.gpio18);
info!("v4: gpio18 one_wire ok");
let tank_power_pin = Output::new(peripherals.gpio11, Level::Low, OutputConfig::default());
info!("v4: gpio11 tank_power ok");
let flow_sensor_pin = Input::new(
peripherals.gpio4,
InputConfig::default().with_pull(Pull::Up),
);
info!("v4: gpio4 flow_sensor ok");
info!("v4: creating tank sensor");
let tank_sensor = TankSensor::create(
one_wire_pin,
peripherals.adc1,
@@ -191,12 +200,17 @@ pub(crate) async fn create_v4(
flow_sensor_pin,
peripherals.pcnt1,
)?;
info!("v4: tank sensor ok");
let can_power = Output::new(peripherals.gpio22, Level::Low, OutputConfig::default());
info!("v4: gpio22 can_power ok");
let solar_is_day = Input::new(peripherals.gpio7, InputConfig::default());
info!("v4: gpio7 solar_is_day ok");
let light = Output::new(peripherals.gpio10, Level::Low, Default::default());
info!("v4: gpio10 light ok");
let charge_indicator = Output::new(peripherals.gpio3, Level::Low, Default::default());
info!("v4: gpio3 charge_indicator ok");
info!("Start pump expander");
let pump_device = I2cDevice::new(I2C_DRIVER.get().await);

View File

@@ -10,17 +10,20 @@ use esp_hal::pcnt::channel::EdgeMode::{Hold, Increment};
use esp_hal::pcnt::unit::Unit;
use esp_hal::peripherals::GPIO5;
use esp_hal::Async;
use esp_println::println;
use log::info;
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
use portable_atomic::{AtomicUsize, Ordering};
unsafe impl Send for TankSensor<'_> {}
static FLOW_OVERFLOW_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct TankSensor<'a> {
one_wire_bus: OneWire<Flex<'a>>,
tank_channel: Adc<'a, ADC1<'a>, Async>,
tank_power: Output<'a>,
tank_pin: AdcPin<GPIO5<'a>, ADC1<'a>, AdcCalLine<ADC1<'a>>>,
flow_counter: Unit<'a, 1>,
flow_unit: Unit<'static, 1>,
}
impl<'a> TankSensor<'a> {
@@ -30,7 +33,7 @@ impl<'a> TankSensor<'a> {
gpio5: GPIO5<'a>,
tank_power: Output<'a>,
flow_sensor: Input,
pcnt1: Unit<'a, 1>,
pcnt1: Unit<'static, 1>,
) -> Result<TankSensor<'a>, FatError> {
one_wire_pin.apply_output_config(
&OutputConfig::default()
@@ -41,47 +44,76 @@ impl<'a> TankSensor<'a> {
one_wire_pin.set_high();
one_wire_pin.set_input_enable(true);
one_wire_pin.set_output_enable(true);
info!("tank: one_wire pin config ok");
let mut adc1_config = AdcConfig::new();
info!("tank: adc config created");
let tank_pin =
adc1_config.enable_pin_with_cal::<_, AdcCalLine<_>>(gpio5, Attenuation::_11dB);
info!("tank: adc pin cal ok");
let tank_channel = Adc::new(adc1, adc1_config).into_async();
info!("tank: adc channel ok");
let one_wire_bus = OneWire::new(one_wire_pin, false);
info!("tank: one_wire bus ok");
pcnt1.set_high_limit(Some(i16::MAX))?;
info!("tank: pcnt high limit ok");
// Reject pulses shorter than ~12.8 µs (1023 APB cycles @ 80 MHz) to suppress EMI noise
// on the sensor cable. Real flow pulses are in the millisecond range.
pcnt1.set_filter(Some(1023)).unwrap();
let ch0 = &pcnt1.channel0;
ch0.set_edge_signal(flow_sensor.peripheral_input());
info!("tank: pcnt edge signal ok");
ch0.set_input_mode(Hold, Increment);
ch0.set_ctrl_mode(Keep, Keep);
info!("tank: pcnt input/ctrl mode ok");
pcnt1.listen();
info!("tank: pcnt listen ok");
Ok(TankSensor {
one_wire_bus,
tank_channel,
tank_power,
tank_pin,
flow_counter: pcnt1,
flow_unit: pcnt1,
})
}
pub fn reset_flow_meter(&mut self) {
self.flow_counter.pause();
self.flow_counter.clear();
// Pause, clear counter, clear any pending interrupt, then reset the overflow counter
// all inside a single critical section to prevent a race where the interrupt fires
// between the overflow reset and the pause.
critical_section::with(|_| {
self.flow_unit.pause();
self.flow_unit.clear();
self.flow_unit.reset_interrupt();
FLOW_OVERFLOW_COUNTER.store(0, Ordering::SeqCst);
});
}
pub fn start_flow_meter(&mut self) {
self.flow_counter.resume();
}
pub fn get_flow_meter_value(&mut self) -> i16 {
self.flow_counter.value()
self.flow_unit.resume();
}
pub fn stop_flow_meter(&mut self) -> i16 {
self.flow_counter.pause();
self.get_flow_meter_value()
critical_section::with(|_| {
let val = self.flow_unit.value();
self.flow_unit.pause();
val
})
}
pub fn get_full_flow_count(&self) -> u32 {
// Read both values inside a single critical section so an overflow interrupt cannot
// fire between the two reads and produce an inconsistent result.
critical_section::with(|_| {
let overflowed = FLOW_OVERFLOW_COUNTER.load(Ordering::SeqCst) as u32;
let current = self.flow_unit.value() as u32;
overflowed * (i16::MAX as u32 + 1) + current
})
}
pub async fn water_temperature_c(&mut self) -> Result<f32, FatError> {
@@ -90,9 +122,9 @@ impl<'a> TankSensor<'a> {
let mut delay = Delay::new();
let presence = self.one_wire_bus.reset(&mut delay)?;
println!("OneWire: reset presence pulse = {}", presence);
info!("OneWire: reset presence pulse = {}", presence);
if !presence {
println!("OneWire: no device responded to reset — check pull-up resistor and wiring");
info!("OneWire: no device responded to reset — check pull-up resistor and wiring");
}
let mut search = DeviceSearch::new();
@@ -100,7 +132,7 @@ impl<'a> TankSensor<'a> {
let mut devices_found = 0u8;
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
devices_found += 1;
println!(
info!(
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
devices_found, device.address[0], device.address
);
@@ -108,16 +140,16 @@ impl<'a> TankSensor<'a> {
water_temp_sensor = Some(device);
break;
} else {
println!("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 {
println!("OneWire: search found zero devices on the bus");
info!("OneWire: search found zero devices on the bus");
}
match water_temp_sensor {
Some(device) => {
println!("Found one wire device: {:?}", device);
info!("Found one wire device: {:?}", device);
let mut water_temp_sensor = DS18B20::new(device)?;
let water_temp: Result<f32, FatError> = loop {
@@ -126,11 +158,11 @@ impl<'a> TankSensor<'a> {
.await;
match &temp {
Ok(res) => {
println!("Water temp is {}", res);
info!("Water temp is {}", res);
break temp;
}
Err(err) => {
println!("Could not get water temp {} attempt {}", err, attempt)
info!("Could not get water temp {} attempt {}", err, attempt)
}
}
if attempt == 5 {
@@ -178,3 +210,15 @@ impl<'a> TankSensor<'a> {
Ok(median_mv / 1000.0)
}
}
#[esp_hal::handler]
pub fn flow_interrupt_handler() {
use esp_hal::peripherals::PCNT;
let pcnt = PCNT::regs();
if pcnt.int_raw().read().cnt_thr_event_u(1).bit() {
if pcnt.u_status(1).read().h_lim().bit() {
FLOW_OVERFLOW_COUNTER.fetch_add(1, Ordering::SeqCst);
}
pcnt.int_clr().write(|w| w.cnt_thr_event_u(1).set_bit());
}
}

View File

@@ -4,7 +4,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
use log::{LevelFilter, Log, Metadata, Record};
const MAX_LIVE_LOG_ENTRIES: usize = 64;
const MAX_LIVE_LOG_ENTRIES: usize = 128;
struct LiveLogBuffer {
entries: Vec<(u64, String)>,

View File

@@ -14,10 +14,10 @@
esp_bootloader_esp_idf::esp_app_desc!();
use esp_backtrace as _;
use crate::hal::PROGRESS_ACTIVE;
use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
use crate::fat_error::FatResult;
use crate::hal::esp::MQTT_STAY_ALIVE;
use crate::hal::PROGRESS_ACTIVE;
use crate::log::log;
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH};
use crate::webserver::http_server;
@@ -67,6 +67,7 @@ mod config;
mod fat_error;
mod hal;
mod log;
mod mqtt;
mod plant_state;
mod tank;
mod webserver;
@@ -83,12 +84,6 @@ enum WaitType {
MqttConfig,
}
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Solar {
current_ma: u32,
voltage_ma: u32,
}
impl WaitType {
fn blink_pattern(&self) -> u64 {
match self {
@@ -114,17 +109,6 @@ struct LightState {
is_day: bool,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
///mqtt struct to track pump activities
struct PumpInfo {
enabled: bool,
pump_ineffective: bool,
median_current_ma: u16,
max_current_ma: u16,
min_current_ma: u16,
error: String,
}
#[derive(Serialize)]
pub struct PumpResult {
median_current_ma: u16,
@@ -132,7 +116,7 @@ pub struct PumpResult {
min_current_ma: u16,
error: String,
flow_value_ml: f32,
flow_value_count: i16,
flow_value_count: u32,
pump_time_s: u16,
overcurrent_ma: Option<u16>,
}
@@ -240,7 +224,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let reboot_now = Arc::new(AtomicBool::new(false));
println!("starting webserver");
let _ = http_server(reboot_now.clone(), stack);
spawner.spawn(http_server(reboot_now.clone(), stack)?);
wait_infinity(board, WaitType::MissingConfig, reboot_now.clone(), UTC).await;
}
@@ -327,7 +311,9 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
log(LogMessage::NormalRun, 0, 0, "", "");
}
let dry_run = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
// if stay alive is true then the hardware will determine state and pretend to do all actions with logging
// this is to help debug what the hardware would do with the current settings applied
let dry_run = mqtt::is_stay_alive();
let tank_state = determine_tank_state(&mut board).await;
@@ -456,6 +442,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
0,
0,
String::new(),
0,
0.0,
)
.await;
@@ -472,6 +460,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
state.max_current_ma,
state.min_current_ma,
state.error,
state.flow_value_count,
state.flow_value_ml,
)
.await;
}
@@ -485,6 +475,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
0,
0,
format!("{err:?}"),
0,
0.0,
)
.await;
}
@@ -635,11 +627,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
match &serde_json::to_string(&light_state) {
Ok(state) => {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/light", state)
.await;
let _ = mqtt::publish("/light", state).await;
}
Err(err) => {
info!("Error publishing lightstate {err}");
@@ -649,39 +637,24 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
let deep_sleep_duration_minutes: u32 =
// if battery soc is unknown assume battery has enough change
if matches!(battery_state, BatteryState::Info(data) if data.state_of_charge < 10) {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "low Volt 12h").await;
let _ = mqtt::publish("/deepsleep", "low Volt 12h").await;
12 * 60
} else if is_day {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "normal 20m").await;
let _ = mqtt::publish("/deepsleep", "normal 20m").await;
20
} else {
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/deepsleep", "night 1h").await;
let _ = mqtt::publish("/deepsleep", "night 1h").await;
60
};
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/state", "sleep")
.await;
info!("Go to sleep for {deep_sleep_duration_minutes} minutes");
let _ = mqtt::publish("/state", "sleep").await;
//determine next event
//is light out of work trigger soon?
//is battery low ??
//is deep sleep
let stay_alive = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
info!("Check stay alive, current state is {stay_alive}");
let stay_alive = mqtt::is_stay_alive();
info!("Check stay alive, current state is {}", stay_alive);
if stay_alive {
let reboot_now = Arc::new(AtomicBool::new(false));
@@ -722,7 +695,7 @@ pub async fn do_secure_pump(
let steps_in_50ms = plant_config.pump_time_s as usize * 20;
let mut current_collector = vec![0_u16; steps_in_50ms];
let mut flow_collector = vec![0_i16; steps_in_50ms];
let mut flow_collector = vec![0_u32; steps_in_50ms];
let mut error = String::new();
let mut first_error = true;
let mut pump_time_ms: u32 = 0;
@@ -773,7 +746,7 @@ pub async fn do_secure_pump(
for step in 0..steps_in_50ms {
let step_start = Instant::now();
let flow_value = board.board_hal.get_tank_sensor()?.get_flow_meter_value();
let flow_value = board.board_hal.get_tank_sensor()?.get_full_flow_count();
flow_collector[step] = flow_value;
let flow_value_ml = flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
@@ -885,7 +858,7 @@ pub async fn do_secure_pump(
pump_time_ms = 1337;
}
board.board_hal.get_tank_sensor()?.stop_flow_meter();
let final_flow_value = board.board_hal.get_tank_sensor()?.get_flow_meter_value();
let final_flow_value = board.board_hal.get_tank_sensor()?.get_full_flow_count();
let flow_value_ml = final_flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
info!("Final flow value is {final_flow_value} with {flow_value_ml} ml");
current_collector.sort();
@@ -922,11 +895,7 @@ async fn publish_tank_state(
let state = serde_json::to_string(
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
)?;
board
.board_hal
.get_esp()
.mqtt_publish("/water", &state)
.await;
let _ = mqtt::publish("/water", &*state).await;
Ok(())
}
@@ -942,11 +911,7 @@ async fn publish_plant_states(
{
let state = serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, timezone_time))?;
let plant_topic = format!("/plant{}", plant_id + 1);
let _ = board
.board_hal
.get_esp()
.mqtt_publish(&plant_topic, &state)
.await;
let _ = mqtt::publish(&plant_topic, &state).await;
}
Ok(())
}
@@ -957,13 +922,12 @@ async fn publish_firmware_info(
ip_address: &str,
timezone_time: &str,
) {
let esp = board.board_hal.get_esp();
esp.mqtt_publish("/firmware/address", ip_address).await;
esp.mqtt_publish("/firmware/state", format!("{:?}", &version).as_str())
mqtt::publish("/firmware/address", ip_address).await;
mqtt::publish("/firmware/state", format!("{:?}", &version).as_str())
.await;
esp.mqtt_publish("/firmware/last_online", timezone_time)
mqtt::publish("/firmware/last_online", timezone_time)
.await;
esp.mqtt_publish("/state", "online").await;
mqtt::publish("/state", "online").await;
}
macro_rules! mk_static {
($t:ty,$val:expr) => {{
@@ -1003,12 +967,7 @@ async fn try_connect_wifi_sntp_mqtt(
let mqtt_connected = if board.board_hal.get_config().network.mqtt_url.is_some() {
let nw_config = board.board_hal.get_config().network.clone();
let nw_config = mk_static!(NetworkConfig, nw_config);
match board
.board_hal
.get_esp()
.mqtt(nw_config, stack, spawner)
.await
{
match mqtt::mqtt_init(nw_config, stack, spawner).await {
Ok(_) => {
info!("Mqtt connection ready");
true
@@ -1052,24 +1011,24 @@ async fn pump_info(
max_current_ma: u16,
min_current_ma: u16,
error: String,
flow_raw: u32,
flow_ml: f32,
) {
let pump_info = PumpInfo {
let pump_info = mqtt::PumpInfo {
enabled: pump_active,
pump_ineffective,
median_current_ma,
max_current_ma,
min_current_ma,
error,
flow_raw,
flow_ml,
};
let pump_topic = format!("/pump{}", plant_id + 1);
match serde_json::to_string(&pump_info) {
Ok(state) => {
board
.board_hal
.get_esp()
.mqtt_publish(&pump_topic, &state)
.await;
let _ = mqtt::publish(&pump_topic, &state).await;
}
Err(err) => {
warn!("Error publishing pump state {err}");
@@ -1082,16 +1041,12 @@ async fn publish_mppt_state(
) -> FatResult<()> {
let current = board.board_hal.get_mptt_current().await?;
let voltage = board.board_hal.get_mptt_voltage().await?;
let solar_state = Solar {
let solar_state = mqtt::Solar {
current_ma: current.as_milliamperes() as u32,
voltage_ma: voltage.as_millivolts() as u32,
};
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
board
.board_hal
.get_esp()
.mqtt_publish("/mppt", &serialized_solar_state_bytes)
.await;
let _ = mqtt::publish("/mppt", &serialized_solar_state_bytes).await;
}
Ok(())
}
@@ -1108,11 +1063,7 @@ async fn publish_battery_state(
Err(_) => "error".to_owned(),
};
{
let _ = board
.board_hal
.get_esp()
.mqtt_publish("/battery", &value)
.await;
let _ = mqtt::publish("/battery", &*value).await;
}
Ok(())
}
@@ -1217,9 +1168,8 @@ async fn wait_infinity(
let cur = board.board_hal.get_time().await;
let timezone_time = cur.with_timezone(&timezone);
let esp = board.board_hal.get_esp();
esp.mqtt_publish("/state", "config").await;
esp.mqtt_publish("/firmware/last_online", &timezone_time.to_rfc3339())
mqtt::publish("/state", "config").await;
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339())
.await;
last_mqtt_update = Some(now);
}
@@ -1273,7 +1223,7 @@ async fn wait_infinity(
hal::PlantHal::feed_watchdog();
if wait_type == WaitType::MqttConfig && !MQTT_STAY_ALIVE.load(Ordering::Relaxed) {
if wait_type == WaitType::MqttConfig && !mqtt::is_stay_alive() {
reboot_now.store(true, Ordering::Relaxed);
}
if reboot_now.load(Ordering::Relaxed) {

View File

@@ -0,0 +1,278 @@
use crate::bail;
use crate::config::NetworkConfig;
use crate::fat_error::{ContextExt, FatError, FatResult};
use crate::hal::PlantHal;
use crate::log::{log, LogMessage};
use alloc::string::String;
use alloc::{format, string::ToString};
use core::sync::atomic::Ordering;
use embassy_executor::Spawner;
use embassy_net::Stack;
use embassy_sync::once_lock::OnceLock;
use embassy_time::{Duration, Timer, WithTimeout};
use log::info;
use mcutie::{
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
QoS, Topic,
};
use portable_atomic::AtomicBool;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
///mqtt struct to track pump activities
pub struct PumpInfo {
pub enabled: bool,
pub pump_ineffective: bool,
pub median_current_ma: u16,
pub max_current_ma: u16,
pub min_current_ma: u16,
pub error: String,
pub flow_raw: u32,
pub flow_ml: f32,
}
#[derive(Serialize, Debug, PartialEq)]
pub struct Solar {
pub current_ma: u32,
pub voltage_ma: u32,
}
static MQTT_CONNECTED_EVENT_RECEIVED: AtomicBool = AtomicBool::new(false);
static MQTT_ROUND_TRIP_RECEIVED: AtomicBool = AtomicBool::new(false);
pub static MQTT_STAY_ALIVE: AtomicBool = AtomicBool::new(false);
static MQTT_BASE_TOPIC: OnceLock<String> = OnceLock::new();
pub fn is_stay_alive() -> bool {
MQTT_STAY_ALIVE.load(Ordering::Relaxed)
}
pub async fn publish(subtopic: &str, message: &str) {
let online = MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed);
if !online {
return;
}
let roundtrip_ok = MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed);
if !roundtrip_ok {
info!("MQTT roundtrip not received yet, dropping message");
return;
}
match publish_inner(subtopic, message).await {
Ok(()) => {}
Err(err) => {
info!(
"Error during mqtt send on topic {subtopic} with message {message:#?} error is {err:?}"
);
}
};
}
async fn publish_inner(subtopic: &str, message: &str) -> FatResult<()> {
if !subtopic.starts_with("/") {
bail!("Subtopic without / at start {}", subtopic);
}
if subtopic.len() > 192 {
bail!("Subtopic exceeds 192 chars {}", subtopic);
}
let base_topic = MQTT_BASE_TOPIC
.try_get()
.context("missing base topic in static!")?;
let full_topic = format!("{base_topic}{subtopic}");
loop {
let result = Topic::General(full_topic.as_str())
.with_display(message)
.retain(true)
.publish()
.await;
match result {
Ok(()) => return Ok(()),
Err(err) => {
let retry = match err {
Error::IOError => false,
Error::TimedOut => true,
Error::TooLarge => false,
Error::PacketError => false,
Error::Invalid => false,
Error::Rejected => false,
};
if !retry {
bail!(
"Error during mqtt send on topic {} with message {:#?} error is {:?}",
&full_topic,
message,
err
);
}
info!(
"Retransmit for {} with message {:#?} error is {:?} retrying {}",
&full_topic, message, err, retry
);
Timer::after(Duration::from_millis(100)).await;
}
}
}
}
macro_rules! mk_static {
($t:ty,$val:expr) => {{
static STATIC_CELL: static_cell::StaticCell<$t> = static_cell::StaticCell::new();
#[deny(unused_attributes)]
let x = STATIC_CELL.uninit().write(($val));
x
}};
}
pub async fn mqtt_init(
network_config: &'static NetworkConfig,
stack: Stack<'static>,
spawner: Spawner,
) -> FatResult<()> {
let base_topic = network_config
.base_topic
.as_ref()
.context("missing base topic")?;
if base_topic.is_empty() {
bail!("Mqtt base_topic was empty")
}
MQTT_BASE_TOPIC
.init(base_topic.to_string())
.map_err(|_| FatError::String {
error: "Error setting basetopic".to_string(),
})?;
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!("{base_topic}/state");
let round_trip_topic = format!("{base_topic}/internal/roundtrip");
let stay_alive_topic = format!("{base_topic}/stay_alive");
let mut builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 0> =
McutieBuilder::new(stack, "plant ctrl", mqtt_url);
if let (Some(mqtt_user), Some(mqtt_password)) = (
network_config.mqtt_user.as_ref(),
network_config.mqtt_password.as_ref(),
) {
builder = builder.with_authentication(mqtt_user, mqtt_password);
info!("With authentification");
}
let lwt = Topic::General(last_will_topic);
let lwt = mk_static!(Topic<String>, lwt);
let lwt = lwt.with_display("lost").retain(true).qos(QoS::AtLeastOnce);
builder = builder.with_last_will(lwt);
//TODO make configurable
builder = builder.with_device_id("plantctrl");
let builder: McutieBuilder<'_, String, PublishDisplay<String, &str>, 2> = builder
.with_subscriptions([
Topic::General(round_trip_topic.clone()),
Topic::General(stay_alive_topic.clone()),
]);
let keep_alive = Duration::from_secs(60 * 60 * 2).as_secs() as u16;
let (receiver, task) = builder.build(keep_alive);
spawner.spawn(mqtt_incoming_task(
receiver,
round_trip_topic.clone(),
stay_alive_topic.clone(),
)?);
spawner.spawn(mqtt_runner(task)?);
log(LogMessage::StayAlive, 0, 0, "", &stay_alive_topic);
log(LogMessage::MqttInfo, 0, 0, "", mqtt_url);
let mqtt_timeout = 15000;
let res = async {
while !MQTT_CONNECTED_EVENT_RECEIVED.load(Ordering::Relaxed) {
PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
bail!("Timeout waiting MQTT connect event")
}
let _ = Topic::General(round_trip_topic.clone())
.with_display("online_text")
.publish()
.await;
let res = async {
while !MQTT_ROUND_TRIP_RECEIVED.load(Ordering::Relaxed) {
PlantHal::feed_watchdog();
Timer::after(Duration::from_millis(100)).await;
}
Ok::<(), FatError>(())
}
.with_timeout(Duration::from_millis(mqtt_timeout as u64))
.await;
if res.is_err() {
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
bail!("Timeout waiting MQTT roundtrip")
}
Ok(())
}
#[embassy_executor::task]
async fn mqtt_runner(
task: McutieTask<'static, String, PublishDisplay<'static, String, &'static str>, 2>,
) {
task.run().await;
}
#[embassy_executor::task]
async fn mqtt_incoming_task(
receiver: McutieReceiver,
round_trip_topic: String,
stay_alive_topic: String,
) {
loop {
let message = receiver.receive().await;
match message {
MqttMessage::Connected => {
info!("Mqtt connected");
MQTT_CONNECTED_EVENT_RECEIVED.store(true, Ordering::Relaxed);
}
MqttMessage::Publish(topic, payload) => match topic {
Topic::DeviceType(_type_topic) => {}
Topic::Device(_device_topic) => {}
Topic::General(topic) => {
let subtopic = topic.as_str();
if subtopic.eq(round_trip_topic.as_str()) {
MQTT_ROUND_TRIP_RECEIVED.store(true, Ordering::Relaxed);
} else if subtopic.eq(stay_alive_topic.as_str()) {
let value = payload.eq_ignore_ascii_case("true".as_ref())
|| payload.eq_ignore_ascii_case("1".as_ref());
let a = match value {
true => 1,
false => 0,
};
log(LogMessage::MqttStayAliveRec, a, 0, "", "");
MQTT_STAY_ALIVE.store(value, Ordering::Relaxed);
} else {
log(LogMessage::UnknownTopic, 0, 0, "", &topic);
}
}
},
MqttMessage::Disconnected => {
MQTT_CONNECTED_EVENT_RECEIVED.store(false, Ordering::Relaxed);
info!("Mqtt disconnected");
}
}
}
}

View File

@@ -3,39 +3,55 @@ title: "BatteryManagement"
date: 2025-01-27
draft: false
description: "a description"
tags: ["battery", "bq34z100"]
tags: ["battery", "bms"]
---
# Battery Management Module
The project contains an additional companion board (Fuel Gauge), with a bq34z100 battery management IC.
It allows to track the health and charge for an external battery and is supposed to be soldered directly to the battery.
The MainBoard contains a connector for power, and additionally a two-pin I2C bus to communicate with the Battery Management module.
The PlantCtrl system uses an external **Battery Management System (BMS)** board that connects to the MainBoard. This module monitors battery voltage, current, and health metrics and communicates with the ESP32-C6 via I2C.
<!-- TODO: Add photo of the new modular Battery Management board -->
# Setup
{{< alert >}}
A protected Battery is required. There is only a very simplistic output voltage adjustment for the MPPT system and no charge termination. It is expected that the battery itself protects against overcharging and deep discharges!
{{< /alert >}}
* BatteryManagement is purely optional, but recommended for solar power.
* If available it will be used for an extended low power deep sleep in case of critical charge.
* If available it will also be used, to reduce the nightlight, if the charge drops to a predefined level, so the nightlight cannot drain to much battery
* If available, all relevant battery metrics will be published via mqtt
Currently the setup requires a custom Ev2400 flasher and the properitary windows software from texas instruments.
{{< alert >}}
Before soldering to the battery
{{< /alert >}}
1. The voltage devider high side must be bridged, while being connected to the computer and being supplied with around 4.2 V from the battery solder leads.
2. Then the data/register for low voltage flash write protection should be set to 0V, as else with the voltage divider and no further configuration, the IC will refuse all write requests.
3. After this the supplied golden image can be used, it will setup the battery for 6Ah and a 4S lifepo. Different values can be adjusted after this to the users liking.
{{< alert >}}
The main board, does not care or process any of the charge discharge limits that can be set. Ensure that the battery can supply enough current as well as accept a 2.4A charging current from the MPPT system.
The open-bms is a custom battery management board designed for this project. It uses a CH32V203 microcontroller to handle battery monitoring and protection. The older bq34z100-based battery management board is deprecated and located in the `__Legay_Unused` folder.
{{< /alert >}}
The golden image sets the statups led up, to be in blinky mode. one very long interval means, that the battery is pretty much full. A few very short flashes mean that the battery is nearly empty. No light means, that the battery is in discharge protection and shut down.
## Hardware
If the red error led lights, something is wrong with the battery. This can be abnormal voltages or a very low health state.
The Battery Management Board features:
* CH32V203 RISC-V microcontroller for battery monitoring
* I2C interface for communication with the MainBoard
* Battery voltage and current sensing
# Todo?
If the battery reports that no discharging should occure, report this and then shutdown without using pumps
{{< alert >}}
The open-bms board does not use the bq34z100 fuel gauge IC. That component was used in an older legacy design now located in the `__Legay_Unused` folder.
{{< /alert >}}
## Integration with MainBoard
The battery management board:
* Connects to the MainBoard via a two-pin I2C bus
* Provides power connection to the battery
* Reports battery metrics via MQTT (if configured)
## Usage
* If available, the system will use battery metrics for deep sleep management when charge is critical
* The nightlight can be automatically disabled if battery level drops below a predefined threshold
* All battery metrics are published via MQTT when configured
* The system includes safety mechanisms to prevent overcharging and deep discharges through the battery's built-in protection circuitry
## Safety Notes
{{< alert >}}
The system requires a battery with built-in protection circuitry. The MPPT system does not include charge termination or overcharge protection - the battery itself must provide these safety features.
{{< /alert >}}
The CH32V203-based BMS monitors battery health and provides status information but does not control the charge/discharge limits. Ensure your battery can handle the maximum charging current from the MPPT system (up to 2.4A).
## Setup
1. **Connect Battery:** Connect your protected battery to the BMS board
2. **_connect MainBoard:** Connect the Battery Management Board to the MainBoard via the I2C bus connector
3. **Power On:** Power on the system and verify communication via MQTT
## Status Indicators
The BMS board includes status LEDs, they behave like every normal powerbank (1-5 lights, animted if charging)

View File

@@ -65,13 +65,9 @@ Software and Hardware may fail: It is your responsibility to ensure that a stuck
{{< /alert >}}
# Todo
## Flow Sensor
There is a input for a flow sensor, currently it is not used as the software is missing.
* Allow monitoring if pumps are actually moving water
* Allow to set limits for how much ml are allowed additinally to the current time limit per watering run
Currently it cannot be set how two sensor should be interpreted and they are only averaged. More complex functions would be nice here, eg. allowing a user settable interpolation (0.8*a+0.2*b)/2 and Min(a,b) as well as max(a,b)

View File

@@ -11,8 +11,6 @@ tags: ["esp32", "hardware"]
<img src="pcb_back.png" class="grid-w50" />
{{< /gallery >}}
<!-- TODO: Add new screenshots of the modular PCB setup -->
{{< gitea server="https://git.mannheim.ccc.de/" repo="C3MA/PlantCtrl" >}}
## Modular Design
@@ -27,17 +25,25 @@ The system now consists of a **MainBoard** which acts as the controller and seve
* **Fully Open Source:** Designed in KiCad
## Available Modules
* **MPPT Charger:** Efficient solar charging for batteries.
* **Pump Driver:** High-current outputs for pumps and valves.
* **Sensor Interface:** Support for multiple moisture sensors.
* **Light Controller:** For LED nightlights or growth lights.
* **MPPT Charger:** Efficient solar charging for batteries using CN3795.
* **Pump Driver:** High-current outputs (up to 3A) for pumps and valves.
* **Sensor Module:** CAN bus-based moisture sensors using CH32V203 microcontroller.
* **Battery Management:** External BMS board with CH32V203 for battery monitoring.
* **Light Controller:** For LED nightlights or growth lights using AP63200.
## Sensor Module (CAN bus)
The standard sensor module features its own **CH32V203 RISC-V microcontroller**, which handles the measurement of soil moisture and communicates the results back to the MainBoard via the CAN bus.
* **Capacity:** Supports up to 16 sensors (typically 8 plants with an A and B sensor each).
* **Reliability:** Digital communication via CAN bus ensures data integrity even over longer cable runs and in electrically noisy environments.
* **Addressing:** The A sensor is always used; the B sensor is optional and suggested for larger planters to provide a better average of the soil moisture.
## Capabilities
* **Moisture Sensors:** Supports multiple capacitive or resistive sensors via expansion modules.
* **Moisture Sensors:** Supports multiple capacitive or resistive sensors via CAN bus-based Sensor Modules.
* **Pumps/Valves:** Support for multiple independent watering zones.
* **Power:**
* Solar powered with MPPT
* Battery powered with optional Battery Management (Fuel Gauge)
* Battery powered with optional Battery Management System (BMS)
* Can also be used with a standard power supply (7-24V)
* **Efficient Power:** Use of high-efficiency DC-DC converters for 3.3V and peripherals.

View File

@@ -6,9 +6,12 @@ description: "a description"
tags: ["firmeware", "upload"]
---
# From Source
The PlantCtrl firmware is written in Rust for the ESP32-C6 RISC-V microcontroller.
## Preconditions
* **Rust:** Current version of `rustup`.
* **ESP32 Toolchain:** `espup` installed and configured.
* **ESP32 Toolchain:** `espup` installed and configured for ESP32-C6.
* **espflash:** Installed via `cargo install espflash`.
* **Node.js:** `npm` installed (for the web interface).
@@ -37,10 +40,8 @@ You can use the provided bash scripts to automate the build and flash process:
You can also update the firmware wirelessly if the system is already running and connected to your network.
1. Generate the OTA binary:
```bash
cargo build --release
```
2. The binary will be at `target/riscv32imac-unknown-none-elf/release/plant-ctrl2`.
**`./image.sh`**
2. The binary will be `image.bin`.
3. Open the PlantCtrl web interface in your browser.
4. Navigate to the **OTA** section.
5. Upload the `plant-ctrl2` file.

View File

@@ -6,23 +6,26 @@ description: "a description"
tags: ["mqtt", "esp"]
---
# MQTT
A configured MQTT server will receive statistical and status data from the controller.
The PlantCtrl firmware publishes comprehensive status and telemetry data via MQTT when configured. The system uses the **mcutie** crate for Home Assistant integration and standard MQTT topics.
### Topics
| Topic | Example | Description |
|-------|---------|-------------|
| `firmware/address` | `192.168.1.2` | IP address in station mode |
| `firmware/state` | `VersionInfo { ... }` | Debug information about the current firmware and OTA slots |
| `firmware/state` | `{...}` | Debug information about the current firmware and OTA slots |
| `firmware/last_online` | `2025-01-22T08:56:46.664+01:00` | Last time the board was online |
| `state` | `online` | Current state of the controller |
| `mppt` | `{"current_ma":1200,"voltage_ma":18500}` | MPPT charging metrics |
| `battery` | `{"Info":{"voltage_milli_volt":12860,"average_current_milli_ampere":-16,...}}` | Battery health and charge data |
| `water` | `{"enough_water":true,"warn_level":false,"left_ml":1337,...}` | Water tank status |
| `plant{1-8}` | `{"sensor_a":...,"sensor_b":...,"mode":"TargetMoisture",...}` | Detailed status for each plant slot |
| `pump{1-8}` | `{"enabled":true,"pump_ineffective":false,...}` | Metrics for the last pump activity |
| `mppt` | `{"current_ma":1200,"voltage_ma":18500}` | MPPT charging metrics (current and voltage from solar panel) |
| `battery` | `{"Info":{"voltage_milli_volt":12860,"state_of_charge":95,...}}` | Battery health and charge data from the BMS |
| `water` | `{"enough_water":true,"warn_level":false,"left_ml":1337,...}` | Water tank status (level, temperature, frozen detection) |
| `plant{1-8}` | `{"sensor_a":...,"sensor_b":...,"mode":"TargetMoisture",...}` | Detailed status for each plant slot including moisture sensors |
| `pump{1-8}` | `{"enabled":true,"median_current_ma":500,...}` | Metrics for each pump output |
| `light` | `{"enabled":true,"active":true,...}` | Night light status |
| `deepsleep` | `night 1h` | Why and how long the ESP will sleep |
| `deepsleep` | `night 1h` | Reason and duration of deep sleep |
Note: The batteries `average_current_milli_ampere` field uses a placeholder value (1337) and should be updated with actual current sensor readings when available.
### Data Structures
@@ -39,14 +42,15 @@ Contains a debug dump of the `VersionInfo` struct:
- `voltage_ma`: Solar panel voltage in mV
#### Battery (`battery`)
Can be `"Unknown"` or an `Info` object:
- `voltage_milli_volt`: Battery voltage
- `average_current_milli_ampere`: Current draw/charge
- `design_milli_ampere_hour`: Battery capacity
- `remaining_milli_ampere_hour`: Remaining capacity
Can be `"Unknown"` or an `Info` object. The battery data comes from a custom BMS (Battery Management System) board that uses the CH32V203 microcontroller with I2C communication.
- `voltage_milli_volt`: Battery voltage in millivolts
- `average_current_milli_ampere`: Current draw/charge in milliamperes (placeholder: 1337)
- `design_milli_ampere_hour`: Battery design capacity in milliampere-hours
- `remaining_milli_ampere_hour`: Remaining capacity in milliampere-hours
- `state_of_charge`: Charge percentage (0-100)
- `state_of_health`: Health percentage (0-100)
- `temperature`: Temperature in degrees Celsius
- `state_of_health`: Health percentage (0-100) based onLifetime capacity vs design capacity
- `temperature`: Battery temperature in degrees Celsius
#### Water (`water`)
- `enough_water`: Boolean, true if level is above empty threshold

View File

@@ -6,9 +6,9 @@ description: "How to compile the project"
tags: ["clone", "compile"]
---
# Preconditions:
* **Rust:** `rustup` installed.
* **ESP32 Toolchain:** `espup` installed.
* **Build Utilities:** `ldproxy` and `espflash` installed.
* **Rust:** `rustup` installed with the Rust toolchain.
* **ESP32 Toolchain:** `espup` installed for ESP32 support.
* **Build Utilities:** `ldproxy` and `espflash` installed via cargo.
* **Node.js:** `npm` installed (for the web interface).
# Cloning the Repository
@@ -19,24 +19,16 @@ cd PlantCtrl/Software/MainBoard/rust
```
# Toolchain Setup
1. **Install Rust:** If not already done, visit [rustup.rs](https://rustup.rs/).
2. **Install ldproxy:**
The project uses Rust with ESP32-C6 support. The toolchain setup involves installing the necessary components:
1. **Rust Toolchain:**
```bash
cargo install ldproxy
```
3. **Install espup:**
```bash
cargo install espup
```
4. **Install ESP toolchain:**
```bash
espup install
```
5. **Install espflash:**
```bash
cargo install espflash
rustup toolchain install stable
rustup default stable
```
# Building the Web Interface
The configuration website is built using TypeScript and Webpack, then embedded into the Rust binary.
```bash
@@ -46,14 +38,7 @@ npx webpack
cd ..
```
# Compiling the Firmware
Build the project using Cargo:
```bash
cargo build --release
```
The resulting binary will be located in `target/riscv32imac-unknown-none-elf/release/plant-ctrl2`.
# Using Build Scripts
# Compiling the Firmware using Build Scripts
To simplify the process, several bash scripts are provided in the `Software/MainBoard/rust` directory:
* **`image_build.sh`**: Automatically builds the web interface, compiles the Rust firmware in release mode, and creates a flashable `image.bin`.