Compare commits
14 Commits
ef0ec47d92
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
2cfb2607a9
|
|||
|
271c1a1383
|
|||
|
a02b84d732
|
|||
| b0f8bcc9da | |||
| 103859120c | |||
| 403517fdb4 | |||
| 11eb8713bf | |||
| d903c2bf52 | |||
| f8f76674ce | |||
| 3cc5a0d2bd | |||
| 3be585ecbf | |||
| 5b1a945ac3 | |||
| f4e050d413 | |||
| 776db785c4 |
396
Software/MainBoard/rust/Cargo.lock
generated
396
Software/MainBoard/rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
heapless = { version = "0.7.17", features = ["serde"] } # stay in sync with mcutie version
|
||||||
static_cell = "2.1.1"
|
static_cell = "2.1.1"
|
||||||
portable-atomic = "1.11.1"
|
portable-atomic = "1.11.1"
|
||||||
|
critical-section = "1"
|
||||||
crc = "3.3.0"
|
crc = "3.3.0"
|
||||||
bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics", "pod_saturating", "extern_crate_alloc"] }
|
bytemuck = { version = "1.24.0", features = ["derive", "min_const_generics", "pod_saturating", "extern_crate_alloc"] }
|
||||||
deranged = "0.5.5"
|
deranged = "0.5.5"
|
||||||
|
|||||||
3
Software/MainBoard/rust/TODO
Normal file
3
Software/MainBoard/rust/TODO
Normal 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
|
||||||
@@ -319,6 +319,9 @@ impl From<BmsProtocolError> for FatError {
|
|||||||
BmsProtocolError::I2cCommunicationError =>FatError::String {
|
BmsProtocolError::I2cCommunicationError =>FatError::String {
|
||||||
error: "I2C communication error".to_string(),
|
error: "I2C communication error".to_string(),
|
||||||
},
|
},
|
||||||
|
BmsProtocolError::ChecksumError => FatError::String {
|
||||||
|
error: "BMS checksum error".to_string(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,6 @@ use esp_radio::wifi::scan::{ScanConfig, ScanTypeConfig};
|
|||||||
use esp_radio::wifi::sta::StationConfig;
|
use esp_radio::wifi::sta::StationConfig;
|
||||||
use esp_radio::wifi::{AuthenticationMethod, Config, Interface, WifiController};
|
use esp_radio::wifi::{AuthenticationMethod, Config, Interface, WifiController};
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use mcutie::{
|
|
||||||
Error, McutieBuilder, McutieReceiver, McutieTask, MqttMessage, PublishDisplay, Publishable,
|
|
||||||
QoS, Topic,
|
|
||||||
};
|
|
||||||
use portable_atomic::AtomicBool;
|
use portable_atomic::AtomicBool;
|
||||||
use sntpc::{get_time, NtpContext, NtpTimestampGenerator, NtpUdpSocket};
|
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";
|
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)]
|
#[derive(Copy, Clone, Default)]
|
||||||
struct Timestamp {
|
struct Timestamp {
|
||||||
stamp: DateTime<Utc>,
|
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)]
|
#[embassy_executor::task(pool_size = 2)]
|
||||||
|
|||||||
@@ -290,7 +290,8 @@ impl PlantHal {
|
|||||||
error: format!("Could not init wifi: {:?}", e),
|
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 {
|
let free_pins = FreePeripherals {
|
||||||
gpio0: peripherals.GPIO0,
|
gpio0: peripherals.GPIO0,
|
||||||
|
|||||||
@@ -161,9 +161,11 @@ pub(crate) async fn create_v4(
|
|||||||
info!("Start v4");
|
info!("Start v4");
|
||||||
let mut awake = Output::new(peripherals.gpio21, Level::High, OutputConfig::default());
|
let mut awake = Output::new(peripherals.gpio21, Level::High, OutputConfig::default());
|
||||||
awake.set_high();
|
awake.set_high();
|
||||||
|
info!("v4: gpio21 awake ok");
|
||||||
|
|
||||||
let mut general_fault = Output::new(peripherals.gpio23, Level::Low, OutputConfig::default());
|
let mut general_fault = Output::new(peripherals.gpio23, Level::Low, OutputConfig::default());
|
||||||
general_fault.set_low();
|
general_fault.set_low();
|
||||||
|
info!("v4: gpio23 general_fault ok");
|
||||||
|
|
||||||
let twai_config = Some(TwaiConfiguration::new(
|
let twai_config = Some(TwaiConfiguration::new(
|
||||||
peripherals.twai,
|
peripherals.twai,
|
||||||
@@ -172,17 +174,24 @@ pub(crate) async fn create_v4(
|
|||||||
TWAI_BAUDRATE,
|
TWAI_BAUDRATE,
|
||||||
TwaiMode::Normal,
|
TwaiMode::Normal,
|
||||||
));
|
));
|
||||||
|
info!("v4: twai config ok");
|
||||||
|
|
||||||
let extra1 = Output::new(peripherals.gpio6, Level::Low, OutputConfig::default());
|
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());
|
let extra2 = Output::new(peripherals.gpio15, Level::Low, OutputConfig::default());
|
||||||
|
info!("v4: gpio15 extra2 ok");
|
||||||
|
|
||||||
let one_wire_pin = Flex::new(peripherals.gpio18);
|
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());
|
let tank_power_pin = Output::new(peripherals.gpio11, Level::Low, OutputConfig::default());
|
||||||
|
info!("v4: gpio11 tank_power ok");
|
||||||
let flow_sensor_pin = Input::new(
|
let flow_sensor_pin = Input::new(
|
||||||
peripherals.gpio4,
|
peripherals.gpio4,
|
||||||
InputConfig::default().with_pull(Pull::Up),
|
InputConfig::default().with_pull(Pull::Up),
|
||||||
);
|
);
|
||||||
|
info!("v4: gpio4 flow_sensor ok");
|
||||||
|
|
||||||
|
info!("v4: creating tank sensor");
|
||||||
let tank_sensor = TankSensor::create(
|
let tank_sensor = TankSensor::create(
|
||||||
one_wire_pin,
|
one_wire_pin,
|
||||||
peripherals.adc1,
|
peripherals.adc1,
|
||||||
@@ -191,12 +200,17 @@ pub(crate) async fn create_v4(
|
|||||||
flow_sensor_pin,
|
flow_sensor_pin,
|
||||||
peripherals.pcnt1,
|
peripherals.pcnt1,
|
||||||
)?;
|
)?;
|
||||||
|
info!("v4: tank sensor ok");
|
||||||
|
|
||||||
let can_power = Output::new(peripherals.gpio22, Level::Low, OutputConfig::default());
|
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());
|
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());
|
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());
|
let charge_indicator = Output::new(peripherals.gpio3, Level::Low, Default::default());
|
||||||
|
info!("v4: gpio3 charge_indicator ok");
|
||||||
|
|
||||||
info!("Start pump expander");
|
info!("Start pump expander");
|
||||||
let pump_device = I2cDevice::new(I2C_DRIVER.get().await);
|
let pump_device = I2cDevice::new(I2C_DRIVER.get().await);
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ 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::peripherals::GPIO5;
|
||||||
use esp_hal::Async;
|
use esp_hal::Async;
|
||||||
use esp_println::println;
|
use log::info;
|
||||||
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
|
use onewire::{ds18b20, Device, DeviceSearch, OneWire, DS18B20};
|
||||||
|
use portable_atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
unsafe impl Send for TankSensor<'_> {}
|
unsafe impl Send for TankSensor<'_> {}
|
||||||
|
|
||||||
|
static FLOW_OVERFLOW_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
pub struct TankSensor<'a> {
|
pub struct TankSensor<'a> {
|
||||||
one_wire_bus: OneWire<Flex<'a>>,
|
one_wire_bus: OneWire<Flex<'a>>,
|
||||||
tank_channel: Adc<'a, ADC1<'a>, Async>,
|
tank_channel: Adc<'a, ADC1<'a>, Async>,
|
||||||
tank_power: Output<'a>,
|
tank_power: Output<'a>,
|
||||||
tank_pin: AdcPin<GPIO5<'a>, ADC1<'a>, AdcCalLine<ADC1<'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> {
|
impl<'a> TankSensor<'a> {
|
||||||
@@ -30,7 +33,7 @@ impl<'a> TankSensor<'a> {
|
|||||||
gpio5: GPIO5<'a>,
|
gpio5: GPIO5<'a>,
|
||||||
tank_power: Output<'a>,
|
tank_power: Output<'a>,
|
||||||
flow_sensor: Input,
|
flow_sensor: Input,
|
||||||
pcnt1: Unit<'a, 1>,
|
pcnt1: Unit<'static, 1>,
|
||||||
) -> Result<TankSensor<'a>, FatError> {
|
) -> Result<TankSensor<'a>, FatError> {
|
||||||
one_wire_pin.apply_output_config(
|
one_wire_pin.apply_output_config(
|
||||||
&OutputConfig::default()
|
&OutputConfig::default()
|
||||||
@@ -41,47 +44,76 @@ impl<'a> TankSensor<'a> {
|
|||||||
one_wire_pin.set_high();
|
one_wire_pin.set_high();
|
||||||
one_wire_pin.set_input_enable(true);
|
one_wire_pin.set_input_enable(true);
|
||||||
one_wire_pin.set_output_enable(true);
|
one_wire_pin.set_output_enable(true);
|
||||||
|
info!("tank: one_wire pin config ok");
|
||||||
|
|
||||||
let mut adc1_config = AdcConfig::new();
|
let mut adc1_config = AdcConfig::new();
|
||||||
|
info!("tank: adc config created");
|
||||||
let tank_pin =
|
let tank_pin =
|
||||||
adc1_config.enable_pin_with_cal::<_, AdcCalLine<_>>(gpio5, Attenuation::_11dB);
|
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();
|
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);
|
let one_wire_bus = OneWire::new(one_wire_pin, false);
|
||||||
|
info!("tank: one_wire bus ok");
|
||||||
|
|
||||||
pcnt1.set_high_limit(Some(i16::MAX))?;
|
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;
|
let ch0 = &pcnt1.channel0;
|
||||||
ch0.set_edge_signal(flow_sensor.peripheral_input());
|
ch0.set_edge_signal(flow_sensor.peripheral_input());
|
||||||
|
info!("tank: pcnt edge signal ok");
|
||||||
ch0.set_input_mode(Hold, Increment);
|
ch0.set_input_mode(Hold, Increment);
|
||||||
ch0.set_ctrl_mode(Keep, Keep);
|
ch0.set_ctrl_mode(Keep, Keep);
|
||||||
|
info!("tank: pcnt input/ctrl mode ok");
|
||||||
|
|
||||||
pcnt1.listen();
|
pcnt1.listen();
|
||||||
|
info!("tank: pcnt listen ok");
|
||||||
|
|
||||||
Ok(TankSensor {
|
Ok(TankSensor {
|
||||||
one_wire_bus,
|
one_wire_bus,
|
||||||
tank_channel,
|
tank_channel,
|
||||||
tank_power,
|
tank_power,
|
||||||
tank_pin,
|
tank_pin,
|
||||||
flow_counter: pcnt1,
|
flow_unit: pcnt1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_flow_meter(&mut self) {
|
pub fn reset_flow_meter(&mut self) {
|
||||||
self.flow_counter.pause();
|
// Pause, clear counter, clear any pending interrupt, then reset the overflow counter —
|
||||||
self.flow_counter.clear();
|
// 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) {
|
pub fn start_flow_meter(&mut self) {
|
||||||
self.flow_counter.resume();
|
self.flow_unit.resume();
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_flow_meter_value(&mut self) -> i16 {
|
|
||||||
self.flow_counter.value()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop_flow_meter(&mut self) -> i16 {
|
pub fn stop_flow_meter(&mut self) -> i16 {
|
||||||
self.flow_counter.pause();
|
critical_section::with(|_| {
|
||||||
self.get_flow_meter_value()
|
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> {
|
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 mut delay = Delay::new();
|
||||||
|
|
||||||
let presence = self.one_wire_bus.reset(&mut delay)?;
|
let presence = self.one_wire_bus.reset(&mut delay)?;
|
||||||
println!("OneWire: reset presence pulse = {}", presence);
|
info!("OneWire: reset presence pulse = {}", presence);
|
||||||
if !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();
|
let mut search = DeviceSearch::new();
|
||||||
@@ -100,7 +132,7 @@ impl<'a> TankSensor<'a> {
|
|||||||
let mut devices_found = 0u8;
|
let mut devices_found = 0u8;
|
||||||
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
|
while let Some(device) = self.one_wire_bus.search_next(&mut search, &mut delay)? {
|
||||||
devices_found += 1;
|
devices_found += 1;
|
||||||
println!(
|
info!(
|
||||||
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
|
"OneWire: found device #{} family=0x{:02X} addr={:02X?}",
|
||||||
devices_found, device.address[0], device.address
|
devices_found, device.address[0], device.address
|
||||||
);
|
);
|
||||||
@@ -108,16 +140,16 @@ impl<'a> TankSensor<'a> {
|
|||||||
water_temp_sensor = Some(device);
|
water_temp_sensor = Some(device);
|
||||||
break;
|
break;
|
||||||
} else {
|
} 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 {
|
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 {
|
match water_temp_sensor {
|
||||||
Some(device) => {
|
Some(device) => {
|
||||||
println!("Found one wire device: {:?}", device);
|
info!("Found one wire device: {:?}", device);
|
||||||
let mut water_temp_sensor = DS18B20::new(device)?;
|
let mut water_temp_sensor = DS18B20::new(device)?;
|
||||||
|
|
||||||
let water_temp: Result<f32, FatError> = loop {
|
let water_temp: Result<f32, FatError> = loop {
|
||||||
@@ -126,11 +158,11 @@ impl<'a> TankSensor<'a> {
|
|||||||
.await;
|
.await;
|
||||||
match &temp {
|
match &temp {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
println!("Water temp is {}", res);
|
info!("Water temp is {}", res);
|
||||||
break temp;
|
break temp;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
println!("Could not get water temp {} attempt {}", err, attempt)
|
info!("Could not get water temp {} attempt {}", err, attempt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if attempt == 5 {
|
if attempt == 5 {
|
||||||
@@ -178,3 +210,15 @@ impl<'a> TankSensor<'a> {
|
|||||||
Ok(median_mv / 1000.0)
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
|||||||
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
|
use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
|
||||||
use log::{LevelFilter, Log, Metadata, Record};
|
use log::{LevelFilter, Log, Metadata, Record};
|
||||||
|
|
||||||
const MAX_LIVE_LOG_ENTRIES: usize = 64;
|
const MAX_LIVE_LOG_ENTRIES: usize = 128;
|
||||||
|
|
||||||
struct LiveLogBuffer {
|
struct LiveLogBuffer {
|
||||||
entries: Vec<(u64, String)>,
|
entries: Vec<(u64, String)>,
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
esp_bootloader_esp_idf::esp_app_desc!();
|
esp_bootloader_esp_idf::esp_app_desc!();
|
||||||
use esp_backtrace as _;
|
use esp_backtrace as _;
|
||||||
|
|
||||||
|
use crate::hal::PROGRESS_ACTIVE;
|
||||||
use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
|
use crate::config::{NetworkConfig, PlantConfig, PlantControllerConfig};
|
||||||
use crate::fat_error::FatResult;
|
use crate::fat_error::FatResult;
|
||||||
use crate::hal::esp::MQTT_STAY_ALIVE;
|
|
||||||
use crate::hal::PROGRESS_ACTIVE;
|
|
||||||
use crate::log::log;
|
use crate::log::log;
|
||||||
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH};
|
use crate::tank::{determine_tank_state, TankError, TankState, WATER_FROZEN_THRESH};
|
||||||
use crate::webserver::http_server;
|
use crate::webserver::http_server;
|
||||||
@@ -67,6 +67,7 @@ mod config;
|
|||||||
mod fat_error;
|
mod fat_error;
|
||||||
mod hal;
|
mod hal;
|
||||||
mod log;
|
mod log;
|
||||||
|
mod mqtt;
|
||||||
mod plant_state;
|
mod plant_state;
|
||||||
mod tank;
|
mod tank;
|
||||||
mod webserver;
|
mod webserver;
|
||||||
@@ -83,12 +84,6 @@ enum WaitType {
|
|||||||
MqttConfig,
|
MqttConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
|
||||||
struct Solar {
|
|
||||||
current_ma: u32,
|
|
||||||
voltage_ma: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WaitType {
|
impl WaitType {
|
||||||
fn blink_pattern(&self) -> u64 {
|
fn blink_pattern(&self) -> u64 {
|
||||||
match self {
|
match self {
|
||||||
@@ -114,17 +109,6 @@ struct LightState {
|
|||||||
is_day: bool,
|
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)]
|
#[derive(Serialize)]
|
||||||
pub struct PumpResult {
|
pub struct PumpResult {
|
||||||
median_current_ma: u16,
|
median_current_ma: u16,
|
||||||
@@ -132,7 +116,7 @@ pub struct PumpResult {
|
|||||||
min_current_ma: u16,
|
min_current_ma: u16,
|
||||||
error: String,
|
error: String,
|
||||||
flow_value_ml: f32,
|
flow_value_ml: f32,
|
||||||
flow_value_count: i16,
|
flow_value_count: u32,
|
||||||
pump_time_s: u16,
|
pump_time_s: u16,
|
||||||
overcurrent_ma: Option<u16>,
|
overcurrent_ma: Option<u16>,
|
||||||
}
|
}
|
||||||
@@ -240,7 +224,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
let reboot_now = Arc::new(AtomicBool::new(false));
|
let reboot_now = Arc::new(AtomicBool::new(false));
|
||||||
println!("starting webserver");
|
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;
|
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, "", "");
|
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;
|
let tank_state = determine_tank_state(&mut board).await;
|
||||||
|
|
||||||
@@ -456,6 +442,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
String::new(),
|
String::new(),
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -472,6 +460,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
state.max_current_ma,
|
state.max_current_ma,
|
||||||
state.min_current_ma,
|
state.min_current_ma,
|
||||||
state.error,
|
state.error,
|
||||||
|
state.flow_value_count,
|
||||||
|
state.flow_value_ml,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -485,6 +475,8 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
format!("{err:?}"),
|
format!("{err:?}"),
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -635,11 +627,7 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
|
|
||||||
match &serde_json::to_string(&light_state) {
|
match &serde_json::to_string(&light_state) {
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
let _ = board
|
let _ = mqtt::publish("/light", state).await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/light", state)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
info!("Error publishing lightstate {err}");
|
info!("Error publishing lightstate {err}");
|
||||||
@@ -649,39 +637,24 @@ async fn safe_main(spawner: Spawner) -> FatResult<()> {
|
|||||||
let deep_sleep_duration_minutes: u32 =
|
let deep_sleep_duration_minutes: u32 =
|
||||||
// if battery soc is unknown assume battery has enough change
|
// if battery soc is unknown assume battery has enough change
|
||||||
if matches!(battery_state, BatteryState::Info(data) if data.state_of_charge < 10) {
|
if matches!(battery_state, BatteryState::Info(data) if data.state_of_charge < 10) {
|
||||||
let _ = board
|
let _ = mqtt::publish("/deepsleep", "low Volt 12h").await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/deepsleep", "low Volt 12h").await;
|
|
||||||
12 * 60
|
12 * 60
|
||||||
} else if is_day {
|
} else if is_day {
|
||||||
let _ = board
|
let _ = mqtt::publish("/deepsleep", "normal 20m").await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/deepsleep", "normal 20m").await;
|
|
||||||
20
|
20
|
||||||
} else {
|
} else {
|
||||||
let _ = board
|
let _ = mqtt::publish("/deepsleep", "night 1h").await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/deepsleep", "night 1h").await;
|
|
||||||
60
|
60
|
||||||
};
|
};
|
||||||
|
let _ = mqtt::publish("/state", "sleep").await;
|
||||||
let _ = board
|
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/state", "sleep")
|
|
||||||
.await;
|
|
||||||
info!("Go to sleep for {deep_sleep_duration_minutes} minutes");
|
|
||||||
|
|
||||||
//determine next event
|
//determine next event
|
||||||
//is light out of work trigger soon?
|
//is light out of work trigger soon?
|
||||||
//is battery low ??
|
//is battery low ??
|
||||||
//is deep sleep
|
//is deep sleep
|
||||||
|
|
||||||
let stay_alive = MQTT_STAY_ALIVE.load(Ordering::Relaxed);
|
let stay_alive = mqtt::is_stay_alive();
|
||||||
info!("Check stay alive, current state is {stay_alive}");
|
info!("Check stay alive, current state is {}", stay_alive);
|
||||||
|
|
||||||
if stay_alive {
|
if stay_alive {
|
||||||
let reboot_now = Arc::new(AtomicBool::new(false));
|
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 steps_in_50ms = plant_config.pump_time_s as usize * 20;
|
||||||
|
|
||||||
let mut current_collector = vec![0_u16; steps_in_50ms];
|
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 error = String::new();
|
||||||
let mut first_error = true;
|
let mut first_error = true;
|
||||||
let mut pump_time_ms: u32 = 0;
|
let mut pump_time_ms: u32 = 0;
|
||||||
@@ -773,7 +746,7 @@ pub async fn do_secure_pump(
|
|||||||
|
|
||||||
for step in 0..steps_in_50ms {
|
for step in 0..steps_in_50ms {
|
||||||
let step_start = Instant::now();
|
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;
|
flow_collector[step] = flow_value;
|
||||||
let flow_value_ml = flow_value as f32 * board.board_hal.get_config().tank.ml_per_pulse;
|
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;
|
pump_time_ms = 1337;
|
||||||
}
|
}
|
||||||
board.board_hal.get_tank_sensor()?.stop_flow_meter();
|
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;
|
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");
|
info!("Final flow value is {final_flow_value} with {flow_value_ml} ml");
|
||||||
current_collector.sort();
|
current_collector.sort();
|
||||||
@@ -922,11 +895,7 @@ async fn publish_tank_state(
|
|||||||
let state = serde_json::to_string(
|
let state = serde_json::to_string(
|
||||||
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
|
&tank_state.as_mqtt_info(&board.board_hal.get_config().tank, &water_temp),
|
||||||
)?;
|
)?;
|
||||||
board
|
let _ = mqtt::publish("/water", &*state).await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/water", &state)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
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 state = serde_json::to_string(&plant_state.to_mqtt_info(plant_conf, timezone_time))?;
|
||||||
let plant_topic = format!("/plant{}", plant_id + 1);
|
let plant_topic = format!("/plant{}", plant_id + 1);
|
||||||
let _ = board
|
let _ = mqtt::publish(&plant_topic, &state).await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish(&plant_topic, &state)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -957,13 +922,12 @@ async fn publish_firmware_info(
|
|||||||
ip_address: &str,
|
ip_address: &str,
|
||||||
timezone_time: &str,
|
timezone_time: &str,
|
||||||
) {
|
) {
|
||||||
let esp = board.board_hal.get_esp();
|
mqtt::publish("/firmware/address", ip_address).await;
|
||||||
esp.mqtt_publish("/firmware/address", ip_address).await;
|
mqtt::publish("/firmware/state", format!("{:?}", &version).as_str())
|
||||||
esp.mqtt_publish("/firmware/state", format!("{:?}", &version).as_str())
|
|
||||||
.await;
|
.await;
|
||||||
esp.mqtt_publish("/firmware/last_online", timezone_time)
|
mqtt::publish("/firmware/last_online", timezone_time)
|
||||||
.await;
|
.await;
|
||||||
esp.mqtt_publish("/state", "online").await;
|
mqtt::publish("/state", "online").await;
|
||||||
}
|
}
|
||||||
macro_rules! mk_static {
|
macro_rules! mk_static {
|
||||||
($t:ty,$val:expr) => {{
|
($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 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 = board.board_hal.get_config().network.clone();
|
||||||
let nw_config = mk_static!(NetworkConfig, nw_config);
|
let nw_config = mk_static!(NetworkConfig, nw_config);
|
||||||
match board
|
match mqtt::mqtt_init(nw_config, stack, spawner).await {
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt(nw_config, stack, spawner)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("Mqtt connection ready");
|
info!("Mqtt connection ready");
|
||||||
true
|
true
|
||||||
@@ -1052,24 +1011,24 @@ async fn pump_info(
|
|||||||
max_current_ma: u16,
|
max_current_ma: u16,
|
||||||
min_current_ma: u16,
|
min_current_ma: u16,
|
||||||
error: String,
|
error: String,
|
||||||
|
flow_raw: u32,
|
||||||
|
flow_ml: f32,
|
||||||
) {
|
) {
|
||||||
let pump_info = PumpInfo {
|
let pump_info = mqtt::PumpInfo {
|
||||||
enabled: pump_active,
|
enabled: pump_active,
|
||||||
pump_ineffective,
|
pump_ineffective,
|
||||||
median_current_ma,
|
median_current_ma,
|
||||||
max_current_ma,
|
max_current_ma,
|
||||||
min_current_ma,
|
min_current_ma,
|
||||||
error,
|
error,
|
||||||
|
flow_raw,
|
||||||
|
flow_ml,
|
||||||
};
|
};
|
||||||
let pump_topic = format!("/pump{}", plant_id + 1);
|
let pump_topic = format!("/pump{}", plant_id + 1);
|
||||||
|
|
||||||
match serde_json::to_string(&pump_info) {
|
match serde_json::to_string(&pump_info) {
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
board
|
let _ = mqtt::publish(&pump_topic, &state).await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish(&pump_topic, &state)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!("Error publishing pump state {err}");
|
warn!("Error publishing pump state {err}");
|
||||||
@@ -1082,16 +1041,12 @@ async fn publish_mppt_state(
|
|||||||
) -> FatResult<()> {
|
) -> FatResult<()> {
|
||||||
let current = board.board_hal.get_mptt_current().await?;
|
let current = board.board_hal.get_mptt_current().await?;
|
||||||
let voltage = board.board_hal.get_mptt_voltage().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,
|
current_ma: current.as_milliamperes() as u32,
|
||||||
voltage_ma: voltage.as_millivolts() as u32,
|
voltage_ma: voltage.as_millivolts() as u32,
|
||||||
};
|
};
|
||||||
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
|
if let Ok(serialized_solar_state_bytes) = serde_json::to_string(&solar_state) {
|
||||||
board
|
let _ = mqtt::publish("/mppt", &serialized_solar_state_bytes).await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/mppt", &serialized_solar_state_bytes)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1108,11 +1063,7 @@ async fn publish_battery_state(
|
|||||||
Err(_) => "error".to_owned(),
|
Err(_) => "error".to_owned(),
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let _ = board
|
let _ = mqtt::publish("/battery", &*value).await;
|
||||||
.board_hal
|
|
||||||
.get_esp()
|
|
||||||
.mqtt_publish("/battery", &value)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1217,9 +1168,8 @@ async fn wait_infinity(
|
|||||||
let cur = board.board_hal.get_time().await;
|
let cur = board.board_hal.get_time().await;
|
||||||
let timezone_time = cur.with_timezone(&timezone);
|
let timezone_time = cur.with_timezone(&timezone);
|
||||||
|
|
||||||
let esp = board.board_hal.get_esp();
|
mqtt::publish("/state", "config").await;
|
||||||
esp.mqtt_publish("/state", "config").await;
|
mqtt::publish("/firmware/last_online", &timezone_time.to_rfc3339())
|
||||||
esp.mqtt_publish("/firmware/last_online", &timezone_time.to_rfc3339())
|
|
||||||
.await;
|
.await;
|
||||||
last_mqtt_update = Some(now);
|
last_mqtt_update = Some(now);
|
||||||
}
|
}
|
||||||
@@ -1273,7 +1223,7 @@ async fn wait_infinity(
|
|||||||
|
|
||||||
hal::PlantHal::feed_watchdog();
|
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);
|
reboot_now.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
if reboot_now.load(Ordering::Relaxed) {
|
if reboot_now.load(Ordering::Relaxed) {
|
||||||
|
|||||||
278
Software/MainBoard/rust/src/mqtt.rs
Normal file
278
Software/MainBoard/rust/src/mqtt.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,39 +3,55 @@ title: "BatteryManagement"
|
|||||||
date: 2025-01-27
|
date: 2025-01-27
|
||||||
draft: false
|
draft: false
|
||||||
description: "a description"
|
description: "a description"
|
||||||
tags: ["battery", "bq34z100"]
|
tags: ["battery", "bms"]
|
||||||
---
|
---
|
||||||
# Battery Management Module
|
# 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 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.
|
||||||
The MainBoard contains a connector for power, and additionally a two-pin I2C bus to communicate with the Battery Management module.
|
|
||||||
|
|
||||||
<!-- TODO: Add photo of the new modular Battery Management board -->
|
|
||||||
|
|
||||||
# Setup
|
|
||||||
{{< alert >}}
|
{{< 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!
|
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 >}}
|
|
||||||
* 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.
|
|
||||||
{{< /alert >}}
|
{{< /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?
|
{{< alert >}}
|
||||||
If the battery reports that no discharging should occure, report this and then shutdown without using pumps
|
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)
|
||||||
@@ -65,13 +65,9 @@ Software and Hardware may fail: It is your responsibility to ensure that a stuck
|
|||||||
{{< /alert >}}
|
{{< /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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ tags: ["esp32", "hardware"]
|
|||||||
<img src="pcb_back.png" class="grid-w50" />
|
<img src="pcb_back.png" class="grid-w50" />
|
||||||
{{< /gallery >}}
|
{{< /gallery >}}
|
||||||
|
|
||||||
<!-- TODO: Add new screenshots of the modular PCB setup -->
|
|
||||||
|
|
||||||
{{< gitea server="https://git.mannheim.ccc.de/" repo="C3MA/PlantCtrl" >}}
|
{{< gitea server="https://git.mannheim.ccc.de/" repo="C3MA/PlantCtrl" >}}
|
||||||
|
|
||||||
## Modular Design
|
## 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
|
* **Fully Open Source:** Designed in KiCad
|
||||||
|
|
||||||
## Available Modules
|
## Available Modules
|
||||||
* **MPPT Charger:** Efficient solar charging for batteries.
|
* **MPPT Charger:** Efficient solar charging for batteries using CN3795.
|
||||||
* **Pump Driver:** High-current outputs for pumps and valves.
|
* **Pump Driver:** High-current outputs (up to 3A) for pumps and valves.
|
||||||
* **Sensor Interface:** Support for multiple moisture sensors.
|
* **Sensor Module:** CAN bus-based moisture sensors using CH32V203 microcontroller.
|
||||||
* **Light Controller:** For LED nightlights or growth lights.
|
* **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
|
## 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.
|
* **Pumps/Valves:** Support for multiple independent watering zones.
|
||||||
* **Power:**
|
* **Power:**
|
||||||
* Solar powered with MPPT
|
* 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)
|
* 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.
|
* **Efficient Power:** Use of high-efficiency DC-DC converters for 3.3V and peripherals.
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ description: "a description"
|
|||||||
tags: ["firmeware", "upload"]
|
tags: ["firmeware", "upload"]
|
||||||
---
|
---
|
||||||
# From Source
|
# From Source
|
||||||
|
|
||||||
|
The PlantCtrl firmware is written in Rust for the ESP32-C6 RISC-V microcontroller.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
* **Rust:** Current version of `rustup`.
|
* **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`.
|
* **espflash:** Installed via `cargo install espflash`.
|
||||||
* **Node.js:** `npm` installed (for the web interface).
|
* **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.
|
You can also update the firmware wirelessly if the system is already running and connected to your network.
|
||||||
|
|
||||||
1. Generate the OTA binary:
|
1. Generate the OTA binary:
|
||||||
```bash
|
**`./image.sh`**
|
||||||
cargo build --release
|
2. The binary will be `image.bin`.
|
||||||
```
|
|
||||||
2. The binary will be at `target/riscv32imac-unknown-none-elf/release/plant-ctrl2`.
|
|
||||||
3. Open the PlantCtrl web interface in your browser.
|
3. Open the PlantCtrl web interface in your browser.
|
||||||
4. Navigate to the **OTA** section.
|
4. Navigate to the **OTA** section.
|
||||||
5. Upload the `plant-ctrl2` file.
|
5. Upload the `plant-ctrl2` file.
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ description: "a description"
|
|||||||
tags: ["mqtt", "esp"]
|
tags: ["mqtt", "esp"]
|
||||||
---
|
---
|
||||||
# MQTT
|
# 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
|
### Topics
|
||||||
|
|
||||||
| Topic | Example | Description |
|
| Topic | Example | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| `firmware/address` | `192.168.1.2` | IP address in station mode |
|
| `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 |
|
| `firmware/last_online` | `2025-01-22T08:56:46.664+01:00` | Last time the board was online |
|
||||||
| `state` | `online` | Current state of the controller |
|
| `state` | `online` | Current state of the controller |
|
||||||
| `mppt` | `{"current_ma":1200,"voltage_ma":18500}` | MPPT charging metrics |
|
| `mppt` | `{"current_ma":1200,"voltage_ma":18500}` | MPPT charging metrics (current and voltage from solar panel) |
|
||||||
| `battery` | `{"Info":{"voltage_milli_volt":12860,"average_current_milli_ampere":-16,...}}` | Battery health and charge data |
|
| `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 |
|
| `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 |
|
| `plant{1-8}` | `{"sensor_a":...,"sensor_b":...,"mode":"TargetMoisture",...}` | Detailed status for each plant slot including moisture sensors |
|
||||||
| `pump{1-8}` | `{"enabled":true,"pump_ineffective":false,...}` | Metrics for the last pump activity |
|
| `pump{1-8}` | `{"enabled":true,"median_current_ma":500,...}` | Metrics for each pump output |
|
||||||
| `light` | `{"enabled":true,"active":true,...}` | Night light status |
|
| `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
|
### Data Structures
|
||||||
|
|
||||||
@@ -39,14 +42,15 @@ Contains a debug dump of the `VersionInfo` struct:
|
|||||||
- `voltage_ma`: Solar panel voltage in mV
|
- `voltage_ma`: Solar panel voltage in mV
|
||||||
|
|
||||||
#### Battery (`battery`)
|
#### Battery (`battery`)
|
||||||
Can be `"Unknown"` or an `Info` object:
|
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
|
|
||||||
- `average_current_milli_ampere`: Current draw/charge
|
- `voltage_milli_volt`: Battery voltage in millivolts
|
||||||
- `design_milli_ampere_hour`: Battery capacity
|
- `average_current_milli_ampere`: Current draw/charge in milliamperes (placeholder: 1337)
|
||||||
- `remaining_milli_ampere_hour`: Remaining capacity
|
- `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_charge`: Charge percentage (0-100)
|
||||||
- `state_of_health`: Health percentage (0-100)
|
- `state_of_health`: Health percentage (0-100) based onLifetime capacity vs design capacity
|
||||||
- `temperature`: Temperature in degrees Celsius
|
- `temperature`: Battery temperature in degrees Celsius
|
||||||
|
|
||||||
#### Water (`water`)
|
#### Water (`water`)
|
||||||
- `enough_water`: Boolean, true if level is above empty threshold
|
- `enough_water`: Boolean, true if level is above empty threshold
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ description: "How to compile the project"
|
|||||||
tags: ["clone", "compile"]
|
tags: ["clone", "compile"]
|
||||||
---
|
---
|
||||||
# Preconditions:
|
# Preconditions:
|
||||||
* **Rust:** `rustup` installed.
|
* **Rust:** `rustup` installed with the Rust toolchain.
|
||||||
* **ESP32 Toolchain:** `espup` installed.
|
* **ESP32 Toolchain:** `espup` installed for ESP32 support.
|
||||||
* **Build Utilities:** `ldproxy` and `espflash` installed.
|
* **Build Utilities:** `ldproxy` and `espflash` installed via cargo.
|
||||||
* **Node.js:** `npm` installed (for the web interface).
|
* **Node.js:** `npm` installed (for the web interface).
|
||||||
|
|
||||||
# Cloning the Repository
|
# Cloning the Repository
|
||||||
@@ -19,24 +19,16 @@ cd PlantCtrl/Software/MainBoard/rust
|
|||||||
```
|
```
|
||||||
|
|
||||||
# Toolchain Setup
|
# 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
|
```bash
|
||||||
cargo install ldproxy
|
rustup toolchain install stable
|
||||||
```
|
rustup default stable
|
||||||
3. **Install espup:**
|
|
||||||
```bash
|
|
||||||
cargo install espup
|
|
||||||
```
|
|
||||||
4. **Install ESP toolchain:**
|
|
||||||
```bash
|
|
||||||
espup install
|
|
||||||
```
|
|
||||||
5. **Install espflash:**
|
|
||||||
```bash
|
|
||||||
cargo install espflash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
# Building the Web Interface
|
# Building the Web Interface
|
||||||
The configuration website is built using TypeScript and Webpack, then embedded into the Rust binary.
|
The configuration website is built using TypeScript and Webpack, then embedded into the Rust binary.
|
||||||
```bash
|
```bash
|
||||||
@@ -46,14 +38,7 @@ npx webpack
|
|||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
# Compiling the Firmware
|
# Compiling the Firmware using Build Scripts
|
||||||
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
|
|
||||||
To simplify the process, several bash scripts are provided in the `Software/MainBoard/rust` directory:
|
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`.
|
* **`image_build.sh`**: Automatically builds the web interface, compiles the Rust firmware in release mode, and creates a flashable `image.bin`.
|
||||||
|
|||||||
Reference in New Issue
Block a user