Compare commits
2 Commits
d2b55fdafb
...
b9bbc4b6e2
Author | SHA1 | Date | |
---|---|---|---|
b9bbc4b6e2 | |||
6fa332e708 |
@ -77,7 +77,7 @@ serde = { version = "1.0.192", features = ["derive"] }
|
|||||||
serde_json = "1.0.108"
|
serde_json = "1.0.108"
|
||||||
|
|
||||||
#timezone
|
#timezone
|
||||||
chrono = { version = "0.4.23", default-features = false , features = ["iana-time-zone" , "alloc"] }
|
chrono = { version = "0.4.23", default-features = false , features = ["iana-time-zone" , "alloc", "serde"] }
|
||||||
chrono-tz = {version="0.8.0", default-features = false , features = [ "filter-by-regex" ]}
|
chrono-tz = {version="0.8.0", default-features = false , features = [ "filter-by-regex" ]}
|
||||||
eeprom24x = "0.7.2"
|
eeprom24x = "0.7.2"
|
||||||
url = "2.5.3"
|
url = "2.5.3"
|
||||||
|
@ -2,6 +2,7 @@ use std::str::FromStr;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::plant_state::PlantWateringMode;
|
||||||
use crate::PLANT_COUNT;
|
use crate::PLANT_COUNT;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
@ -83,8 +84,8 @@ pub struct PlantControllerConfig {
|
|||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct PlantConfig {
|
pub struct PlantConfig {
|
||||||
pub mode: Mode,
|
pub mode: PlantWateringMode,
|
||||||
pub target_moisture: u8,
|
pub target_moisture: f32,
|
||||||
pub pump_time_s: u16,
|
pub pump_time_s: u16,
|
||||||
pub pump_cooldown_min: u16,
|
pub pump_cooldown_min: u16,
|
||||||
pub pump_hour_start: u8,
|
pub pump_hour_start: u8,
|
||||||
@ -96,8 +97,8 @@ pub struct PlantConfig {
|
|||||||
impl Default for PlantConfig {
|
impl Default for PlantConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode: Mode::OFF,
|
mode: PlantWateringMode::OFF,
|
||||||
target_moisture: 40,
|
target_moisture: 40.,
|
||||||
pump_time_s: 30,
|
pump_time_s: 30,
|
||||||
pump_cooldown_min: 60,
|
pump_cooldown_min: 60,
|
||||||
pump_hour_start: 9,
|
pump_hour_start: 9,
|
||||||
@ -108,11 +109,3 @@ impl Default for PlantConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
|
||||||
pub enum Mode {
|
|
||||||
OFF,
|
|
||||||
TargetMoisture,
|
|
||||||
TimerOnly,
|
|
||||||
TimerAndDeadzone,
|
|
||||||
}
|
|
||||||
|
352
rust/src/main.rs
352
rust/src/main.rs
@ -7,7 +7,6 @@ use anyhow::{bail, Result};
|
|||||||
use chrono::{DateTime, Datelike, TimeDelta, Timelike, Utc};
|
use chrono::{DateTime, Datelike, TimeDelta, Timelike, Utc};
|
||||||
use chrono_tz::{Europe::Berlin, Tz};
|
use chrono_tz::{Europe::Berlin, Tz};
|
||||||
|
|
||||||
use config::Mode;
|
|
||||||
use esp_idf_hal::delay::Delay;
|
use esp_idf_hal::delay::Delay;
|
||||||
use esp_idf_sys::{
|
use esp_idf_sys::{
|
||||||
esp_ota_get_app_partition_count, esp_ota_get_running_partition, esp_ota_get_state_partition,
|
esp_ota_get_app_partition_count, esp_ota_get_running_partition, esp_ota_get_state_partition,
|
||||||
@ -28,8 +27,9 @@ mod log;
|
|||||||
pub mod plant_hal;
|
pub mod plant_hal;
|
||||||
mod plant_state;
|
mod plant_state;
|
||||||
mod tank;
|
mod tank;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
use plant_state::{PlantInfo, PlantStateMQTT};
|
use plant_state::{PlantInfo, PlantState};
|
||||||
use tank::*;
|
use tank::*;
|
||||||
|
|
||||||
const TIME_ZONE: Tz = Berlin;
|
const TIME_ZONE: Tz = Berlin;
|
||||||
@ -406,61 +406,58 @@ fn safe_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut plantstate: [PlantInfo; PLANT_COUNT] = core::array::from_fn(|_| PlantInfo {
|
let mut plantstate: [PlantState; PLANT_COUNT] =
|
||||||
..Default::default()
|
core::array::from_fn(|i| PlantState::read_hardware_state(i, &mut board, &config.plants[i]));
|
||||||
});
|
|
||||||
determine_plant_state(
|
|
||||||
&mut plantstate,
|
|
||||||
timezone_time,
|
|
||||||
&tank_state,
|
|
||||||
&config,
|
|
||||||
&mut board,
|
|
||||||
);
|
|
||||||
|
|
||||||
let pump_required = plantstate.iter().any(|it| it.do_water) && !water_frozen;
|
let pump_required = plantstate
|
||||||
|
.iter()
|
||||||
|
.zip(&config.plants)
|
||||||
|
.any(|(it, conf)| it.needs_to_be_watered(&conf, &timezone_time))
|
||||||
|
&& !water_frozen;
|
||||||
if pump_required {
|
if pump_required {
|
||||||
log(log::LogMessage::EnableMain, dry_run as u32, 0, "", "");
|
log(log::LogMessage::EnableMain, dry_run as u32, 0, "", "");
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
board.any_pump(true)?;
|
board.any_pump(true)?; // what does this do? Does it need to be reset?
|
||||||
}
|
|
||||||
for plant in 0..PLANT_COUNT {
|
|
||||||
let state = &mut plantstate[plant];
|
|
||||||
if state.do_water {
|
|
||||||
let plant_config = &config.plants[plant];
|
|
||||||
state.consecutive_pump_count = board.consecutive_pump_count(plant) + 1;
|
|
||||||
board.store_consecutive_pump_count(plant, state.consecutive_pump_count);
|
|
||||||
if state.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 {
|
|
||||||
log(
|
|
||||||
log::LogMessage::ConsecutivePumpCountLimit,
|
|
||||||
state.consecutive_pump_count as u32,
|
|
||||||
plant_config.max_consecutive_pump_count as u32,
|
|
||||||
&plant.to_string(),
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
state.not_effective = true;
|
|
||||||
board.fault(plant, true);
|
|
||||||
}
|
}
|
||||||
|
for (plant_id, (state, plant_config)) in plantstate.iter().zip(&config.plants).enumerate() {
|
||||||
|
if state.needs_to_be_watered(&plant_config, &timezone_time) {
|
||||||
|
let pump_count = board.consecutive_pump_count(plant_id) + 1;
|
||||||
|
board.store_consecutive_pump_count(plant_id, pump_count);
|
||||||
|
//TODO(judge) where to put this?
|
||||||
|
//if state.consecutive_pump_count > plant_config.max_consecutive_pump_count as u32 {
|
||||||
|
// log(
|
||||||
|
// log::LogMessage::ConsecutivePumpCountLimit,
|
||||||
|
// state.consecutive_pump_count as u32,
|
||||||
|
// plant_config.max_consecutive_pump_count as u32,
|
||||||
|
// &plant.to_string(),
|
||||||
|
// "",
|
||||||
|
// );
|
||||||
|
// state.not_effective = true;
|
||||||
|
// board.fault(plant, true);
|
||||||
|
//}
|
||||||
log(
|
log(
|
||||||
log::LogMessage::PumpPlant,
|
log::LogMessage::PumpPlant,
|
||||||
(plant + 1) as u32,
|
(plant_id + 1) as u32,
|
||||||
plant_config.pump_time_s as u32,
|
plant_config.pump_time_s as u32,
|
||||||
&dry_run.to_string(),
|
&dry_run.to_string(),
|
||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
board.store_last_pump_time(plant, cur);
|
board.store_last_pump_time(plant_id, cur);
|
||||||
board.last_pump_time(plant);
|
board.last_pump_time(plant_id);
|
||||||
state.active = true;
|
//state.active = true;
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
board.pump(plant, true)?;
|
board.pump(plant_id, true)?;
|
||||||
for _ in 0..plant_config.pump_time_s {
|
Delay::new_default().delay_ms(1000*plant_config.pump_time_s as u32);
|
||||||
Delay::new_default().delay_ms(1000);
|
board.pump(plant_id, false)?;
|
||||||
}
|
}
|
||||||
board.pump(plant, false)?;
|
} else if !state.pump_in_timeout(&plant_config, &timezone_time){
|
||||||
|
// plant does not need to be watered and is not in timeout
|
||||||
|
// -> reset consecutive pump count
|
||||||
|
board.store_consecutive_pump_count(plant_id, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
//update_plant_state(&mut plantstate, &mut board, &config);
|
||||||
update_plant_state(&mut plantstate, &mut board, &config);
|
|
||||||
|
|
||||||
let is_day = board.is_day();
|
let is_day = board.is_day();
|
||||||
let state_of_charge = board.state_charge_percent().unwrap_or(0);
|
let state_of_charge = board.state_charge_percent().unwrap_or(0);
|
||||||
@ -568,279 +565,6 @@ fn publish_battery_state(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn determine_state_target_moisture_for_plant(
|
|
||||||
board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>,
|
|
||||||
plant: usize,
|
|
||||||
state: &mut PlantInfo,
|
|
||||||
config: &PlantControllerConfig,
|
|
||||||
tank_state: &TankState,
|
|
||||||
cur: DateTime<Tz>,
|
|
||||||
) {
|
|
||||||
let plant_config = &config.plants[plant];
|
|
||||||
if plant_config.mode == Mode::OFF {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match board.measure_moisture_hz(plant, plant_hal::Sensor::A) {
|
|
||||||
Ok(a) => {
|
|
||||||
state.a_raw = Some(a);
|
|
||||||
let mapped = map_range_moisture(a as f32);
|
|
||||||
match mapped {
|
|
||||||
Ok(result) => state.a = Some(result),
|
|
||||||
Err(err) => {
|
|
||||||
state.sensor_error_a = Some(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
state.sensor_error_a = Some(SensorError::Unknown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if plant_config.sensor_b {
|
|
||||||
match board.measure_moisture_hz(plant, plant_hal::Sensor::B) {
|
|
||||||
Ok(b) => {
|
|
||||||
state.b_raw = Some(b);
|
|
||||||
let mapped = map_range_moisture(b as f32);
|
|
||||||
match mapped {
|
|
||||||
Ok(result) => state.b = Some(result),
|
|
||||||
Err(err) => {
|
|
||||||
state.sensor_error_b = Some(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
state.sensor_error_b = Some(SensorError::Unknown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//FIXME how to average analyze whatever?
|
|
||||||
let a_low = state.a.is_some() && state.a.unwrap() < plant_config.target_moisture;
|
|
||||||
let b_low = state.b.is_some() && state.b.unwrap() < plant_config.target_moisture;
|
|
||||||
|
|
||||||
if a_low || b_low {
|
|
||||||
state.dry = true;
|
|
||||||
match tank_state.enough_water(&config.tank) {
|
|
||||||
Err(_tank_err) => {
|
|
||||||
if !config.tank.tank_allow_pumping_if_sensor_error {
|
|
||||||
state.no_water = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// when no tank error, if plant should be watered depends on if enough water is in tank
|
|
||||||
// no_water behaves inversly to enough_water
|
|
||||||
Ok(enough_water) => state.no_water = !enough_water,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let duration = TimeDelta::try_minutes(plant_config.pump_cooldown_min as i64).unwrap();
|
|
||||||
let last_pump = board.last_pump_time(plant);
|
|
||||||
match last_pump {
|
|
||||||
Some(last_pump) => {
|
|
||||||
let next_pump = last_pump + duration;
|
|
||||||
if next_pump > cur {
|
|
||||||
let local_time = next_pump.with_timezone(&TIME_ZONE);
|
|
||||||
state.next_pump = Some(local_time);
|
|
||||||
state.cooldown = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!(
|
|
||||||
"Could not restore last pump for plant {}, restoring",
|
|
||||||
plant + 1
|
|
||||||
);
|
|
||||||
board.store_last_pump_time(plant, DateTime::from_timestamp_millis(0).unwrap());
|
|
||||||
state.pump_error = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !in_time_range(
|
|
||||||
&cur,
|
|
||||||
plant_config.pump_hour_start,
|
|
||||||
plant_config.pump_hour_end,
|
|
||||||
) {
|
|
||||||
state.out_of_work_hour = true;
|
|
||||||
}
|
|
||||||
if state.dry && !state.no_water && !state.cooldown && !state.out_of_work_hour {
|
|
||||||
state.do_water = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn determine_state_timer_only_for_plant(
|
|
||||||
board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>,
|
|
||||||
plant: usize,
|
|
||||||
state: &mut PlantInfo,
|
|
||||||
config: &PlantControllerConfig,
|
|
||||||
tank_state: &TankState,
|
|
||||||
cur: DateTime<Tz>,
|
|
||||||
) {
|
|
||||||
let plant_config = &config.plants[plant];
|
|
||||||
let duration = TimeDelta::try_minutes(plant_config.pump_cooldown_min as i64).unwrap();
|
|
||||||
|
|
||||||
let last_pump = board.last_pump_time(plant);
|
|
||||||
match last_pump {
|
|
||||||
Some(last_pump) => {
|
|
||||||
let next_pump = last_pump + duration;
|
|
||||||
if next_pump > cur {
|
|
||||||
let europe_time = next_pump.with_timezone(&TIME_ZONE);
|
|
||||||
state.next_pump = Some(europe_time);
|
|
||||||
state.cooldown = true;
|
|
||||||
} else {
|
|
||||||
match tank_state.enough_water(&config.tank) {
|
|
||||||
Err(_tank_err) => {
|
|
||||||
if !config.tank.tank_allow_pumping_if_sensor_error {
|
|
||||||
state.do_water = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(enough_water) => {
|
|
||||||
state.no_water = !enough_water;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!(
|
|
||||||
"Could not restore last pump for plant {}, restoring",
|
|
||||||
plant + 1
|
|
||||||
);
|
|
||||||
board.store_last_pump_time(plant, DateTime::from_timestamp_millis(0).unwrap());
|
|
||||||
state.pump_error = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn determine_state_timer_and_deadzone_for_plant(
|
|
||||||
board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>,
|
|
||||||
plant: usize,
|
|
||||||
state: &mut PlantInfo,
|
|
||||||
config: &PlantControllerConfig,
|
|
||||||
tank_state: &TankState,
|
|
||||||
cur: DateTime<Tz>,
|
|
||||||
) {
|
|
||||||
let plant_config = &config.plants[plant];
|
|
||||||
let duration = TimeDelta::try_minutes(plant_config.pump_cooldown_min as i64).unwrap();
|
|
||||||
|
|
||||||
let last_pump = board.last_pump_time(plant);
|
|
||||||
match last_pump {
|
|
||||||
Some(last_pump) => {
|
|
||||||
let next_pump = last_pump + duration;
|
|
||||||
if next_pump > cur {
|
|
||||||
let europe_time = next_pump.with_timezone(&TIME_ZONE);
|
|
||||||
state.next_pump = Some(europe_time);
|
|
||||||
state.cooldown = true;
|
|
||||||
}
|
|
||||||
if !in_time_range(
|
|
||||||
&cur,
|
|
||||||
plant_config.pump_hour_start,
|
|
||||||
plant_config.pump_hour_end,
|
|
||||||
) {
|
|
||||||
state.out_of_work_hour = true;
|
|
||||||
}
|
|
||||||
if !state.cooldown && !state.out_of_work_hour {
|
|
||||||
match tank_state.enough_water(&config.tank) {
|
|
||||||
Err(_tank_err) => {
|
|
||||||
if !config.tank.tank_allow_pumping_if_sensor_error {
|
|
||||||
state.do_water = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(enough_water) => {
|
|
||||||
state.no_water = !enough_water;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!(
|
|
||||||
"Could not restore last pump for plant {}, restoring",
|
|
||||||
plant + 1
|
|
||||||
);
|
|
||||||
board.store_last_pump_time(plant, DateTime::from_timestamp_millis(0).unwrap());
|
|
||||||
state.pump_error = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn determine_plant_state(
|
|
||||||
plantstate: &mut [PlantInfo; PLANT_COUNT],
|
|
||||||
cur: DateTime<Tz>,
|
|
||||||
tank_state: &TankState,
|
|
||||||
config: &PlantControllerConfig,
|
|
||||||
board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>,
|
|
||||||
) {
|
|
||||||
for plant in 0..PLANT_COUNT {
|
|
||||||
let state = &mut plantstate[plant];
|
|
||||||
let plant_config = &config.plants[plant];
|
|
||||||
match plant_config.mode {
|
|
||||||
config::Mode::OFF => {}
|
|
||||||
config::Mode::TargetMoisture => {
|
|
||||||
determine_state_target_moisture_for_plant(
|
|
||||||
board, plant, state, config, tank_state, cur,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
config::Mode::TimerOnly => {
|
|
||||||
determine_state_timer_only_for_plant(board, plant, state, config, tank_state, cur);
|
|
||||||
}
|
|
||||||
config::Mode::TimerAndDeadzone => {
|
|
||||||
determine_state_timer_and_deadzone_for_plant(
|
|
||||||
board, plant, state, config, tank_state, cur,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if state.sensor_error_a.is_some() || state.sensor_error_b.is_some() {
|
|
||||||
board.fault(plant, true);
|
|
||||||
}
|
|
||||||
if !state.dry {
|
|
||||||
state.consecutive_pump_count = 0;
|
|
||||||
board.store_consecutive_pump_count(plant, 0);
|
|
||||||
}
|
|
||||||
println!("Plant {} state is {:?}", plant, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_plant_state(
|
|
||||||
plantstate: &mut [PlantInfo; PLANT_COUNT],
|
|
||||||
board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>,
|
|
||||||
config: &PlantControllerConfig,
|
|
||||||
) {
|
|
||||||
for plant in 0..PLANT_COUNT {
|
|
||||||
let state = &plantstate[plant];
|
|
||||||
let plant_config = &config.plants[plant];
|
|
||||||
|
|
||||||
let mode = format!("{:?}", plant_config.mode);
|
|
||||||
|
|
||||||
let plant_dto = PlantStateMQTT {
|
|
||||||
a: &sensor_to_string(
|
|
||||||
&state.a,
|
|
||||||
&state.sensor_error_a,
|
|
||||||
plant_config.mode != Mode::OFF,
|
|
||||||
),
|
|
||||||
a_raw: &state.a_raw.unwrap_or(0).to_string(),
|
|
||||||
b: &sensor_to_string(&state.b, &state.sensor_error_b, plant_config.sensor_b),
|
|
||||||
b_raw: &state.b_raw.unwrap_or(0).to_string(),
|
|
||||||
active: state.active,
|
|
||||||
mode: &mode,
|
|
||||||
last_pump: &time_to_string_utc(board.last_pump_time(plant)),
|
|
||||||
next_pump: &time_to_string(state.next_pump),
|
|
||||||
consecutive_pump_count: state.consecutive_pump_count,
|
|
||||||
cooldown: state.cooldown,
|
|
||||||
dry: state.dry,
|
|
||||||
not_effective: state.not_effective,
|
|
||||||
out_of_work_hour: state.out_of_work_hour,
|
|
||||||
pump_error: state.pump_error,
|
|
||||||
};
|
|
||||||
|
|
||||||
match serde_json::to_string(&plant_dto) {
|
|
||||||
Ok(state) => {
|
|
||||||
let plant_topic = format!("/plant{}", plant + 1);
|
|
||||||
let _ = board.mqtt_publish(&config, &plant_topic, state.as_bytes());
|
|
||||||
//reduce speed as else messages will be dropped
|
|
||||||
Delay::new_default().delay_ms(200);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
println!("Error publishing lightstate {}", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wait_infinity(wait_type: WaitType, reboot_now: Arc<AtomicBool>) -> ! {
|
fn wait_infinity(wait_type: WaitType, reboot_now: Arc<AtomicBool>) -> ! {
|
||||||
let delay = wait_type.blink_pattern();
|
let delay = wait_type.blink_pattern();
|
||||||
|
|
||||||
|
@ -603,7 +603,7 @@ impl PlantCtrlBoard<'_> {
|
|||||||
self.time()
|
self.time()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn measure_moisture_hz(&mut self, plant: usize, sensor: Sensor) -> Result<u32> {
|
pub fn measure_moisture_hz(&mut self, plant: usize, sensor: Sensor) -> Result<f32> {
|
||||||
let sensor_channel = match sensor {
|
let sensor_channel = match sensor {
|
||||||
Sensor::A => match plant {
|
Sensor::A => match plant {
|
||||||
0 => SENSOR_A_1,
|
0 => SENSOR_A_1,
|
||||||
@ -629,7 +629,7 @@ impl PlantCtrlBoard<'_> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut results = [0_u32; REPEAT_MOIST_MEASURE];
|
let mut results = [0_f32; REPEAT_MOIST_MEASURE];
|
||||||
for repeat in 0..REPEAT_MOIST_MEASURE {
|
for repeat in 0..REPEAT_MOIST_MEASURE {
|
||||||
self.signal_counter.counter_pause()?;
|
self.signal_counter.counter_pause()?;
|
||||||
self.signal_counter.counter_clear()?;
|
self.signal_counter.counter_clear()?;
|
||||||
@ -644,7 +644,7 @@ impl PlantCtrlBoard<'_> {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let delay = Delay::new_default();
|
let delay = Delay::new_default();
|
||||||
let measurement = 100;
|
let measurement = 100; // TODO what is this scaling factor? what is its purpose?
|
||||||
let factor = 1000 as f32 / measurement as f32;
|
let factor = 1000 as f32 / measurement as f32;
|
||||||
|
|
||||||
//give some time to stabilize
|
//give some time to stabilize
|
||||||
@ -658,7 +658,7 @@ impl PlantCtrlBoard<'_> {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
delay.delay_ms(10);
|
delay.delay_ms(10);
|
||||||
let unscaled = self.signal_counter.get_counter_value()? as i32;
|
let unscaled = self.signal_counter.get_counter_value()? as i32;
|
||||||
let hz = (unscaled as f32 * factor) as u32;
|
let hz = unscaled as f32 * factor;
|
||||||
log(
|
log(
|
||||||
LogMessage::RawMeasure,
|
LogMessage::RawMeasure,
|
||||||
unscaled as u32,
|
unscaled as u32,
|
||||||
@ -668,7 +668,7 @@ impl PlantCtrlBoard<'_> {
|
|||||||
);
|
);
|
||||||
results[repeat] = hz;
|
results[repeat] = hz;
|
||||||
}
|
}
|
||||||
results.sort();
|
results.sort_by(|a,b| a.partial_cmp(b).unwrap()); // floats don't seem to implement total_ord
|
||||||
|
|
||||||
let mid = results.len() / 2;
|
let mid = results.len() / 2;
|
||||||
|
|
||||||
|
@ -1,68 +1,112 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serde::Serialize;
|
use measurements::humidity;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{config, plant_hal};
|
use crate::{
|
||||||
|
config::{self, PlantConfig},
|
||||||
|
plant_hal::{self, PLANT_COUNT},
|
||||||
|
};
|
||||||
|
|
||||||
const MOIST_SENSOR_MAX_FREQUENCY: u32 = 5500; // 60kHz (500Hz margin)
|
const MOIST_SENSOR_MAX_FREQUENCY: f32 = 5500.; // 60kHz (500Hz margin)
|
||||||
const MOIST_SENSOR_MIN_FREQUENCY: u32 = 150; // this is really really dry, think like cactus levels
|
const MOIST_SENSOR_MIN_FREQUENCY: f32 = 150.; // this is really really dry, think like cactus levels
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Serialize)]
|
||||||
pub enum HumiditySensorError {
|
pub enum HumiditySensorError {
|
||||||
ShortCircuit { hz: f32, max: f32 },
|
ShortCircuit { hz: f32, max: f32 },
|
||||||
OpenLoop{hz: f32, min: f32}
|
OpenLoop { hz: f32, min: f32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Serialize)]
|
||||||
pub enum HumiditySensorState {
|
pub enum HumiditySensorState {
|
||||||
Disabled,
|
Disabled,
|
||||||
HumidityValue{raw_hz: u32, moisture_percent: f32},
|
HumidityValue { raw_hz: f32, moisture_percent: f32 },
|
||||||
SensorError(HumiditySensorError),
|
SensorError(HumiditySensorError),
|
||||||
BoardError(String)
|
BoardError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HumiditySensorState {
|
impl HumiditySensorState {
|
||||||
|
pub fn is_err(&self) -> bool {
|
||||||
|
matches!(self, Self::SensorError(_)) || matches!(self, Self::BoardError(_))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PumpError {}
|
pub fn moisture_percent(&self) -> Option<f32> {
|
||||||
|
if let HumiditySensorState::HumidityValue {
|
||||||
|
raw_hz,
|
||||||
|
moisture_percent,
|
||||||
|
} = self
|
||||||
|
{
|
||||||
|
Some(*moisture_percent)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HumiditySensorState {}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Serialize)]
|
||||||
|
pub enum PumpError {
|
||||||
|
PumpNotWorking {
|
||||||
|
failed_attempts: usize,
|
||||||
|
max_allowed_failures: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PumpState {
|
pub struct PumpState {
|
||||||
consecutive_pump_count: u32,
|
consecutive_pump_count: u32,
|
||||||
previous_pump: Option<DateTime<Utc>>
|
previous_pump: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
|
pub enum PlantWateringMode {
|
||||||
|
OFF,
|
||||||
|
TargetMoisture,
|
||||||
|
TimerOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PlantError {}
|
pub enum PlantError {}
|
||||||
|
|
||||||
pub struct PlantState {
|
pub struct PlantState {
|
||||||
sensor_a: HumiditySensorState,
|
pub sensor_a: HumiditySensorState,
|
||||||
sensor_b: HumiditySensorState,
|
pub sensor_b: HumiditySensorState,
|
||||||
pump: PumpState,
|
pub pump: PumpState,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_range_moisture(s: f32) -> Result<f32, HumiditySensorError> {
|
fn map_range_moisture(s: f32) -> Result<f32, HumiditySensorError> {
|
||||||
if s < MOIST_SENSOR_MIN_FREQUENCY {
|
if s < MOIST_SENSOR_MIN_FREQUENCY {
|
||||||
return Err(HumiditySensorError::OpenCircuit { hz: s, min: FROM.0 });
|
return Err(HumiditySensorError::OpenLoop {
|
||||||
|
hz: s,
|
||||||
|
min: MOIST_SENSOR_MIN_FREQUENCY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if s > MOIST_SENSOR_MAX_FREQUENCY {
|
if s > MOIST_SENSOR_MAX_FREQUENCY {
|
||||||
return Err(HumiditySensorError::ShortCircuit { hz: s, max: FROM.1 });
|
return Err(HumiditySensorError::ShortCircuit {
|
||||||
|
hz: s,
|
||||||
|
max: MOIST_SENSOR_MAX_FREQUENCY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let moisture_percent = (s - MOIST_SENSOR_MIN_FREQUENCY) * 100 / (MOIST_SENSOR_MAX_FREQUENCY - MOIST_SENSOR_MIN_FREQUENCY);
|
let moisture_percent = (s - MOIST_SENSOR_MIN_FREQUENCY) * 100.0
|
||||||
|
/ (MOIST_SENSOR_MAX_FREQUENCY - MOIST_SENSOR_MIN_FREQUENCY);
|
||||||
|
|
||||||
return Ok(moisture_percent);
|
return Ok(moisture_percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl PlantState {
|
impl PlantState {
|
||||||
pub fn read_hardware_state(
|
pub fn read_hardware_state(
|
||||||
plant_id: usize,
|
plant_id: usize,
|
||||||
board: &mut plant_hal::PlantCtrlBoard,
|
board: &mut plant_hal::PlantCtrlBoard,
|
||||||
config: &config::PlantConfig
|
config: &config::PlantConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let sensor_a = if config.sensor_a {
|
let sensor_a = if config.sensor_a {
|
||||||
match board.measure_moisture_hz(plant_id, plant_hal::Sensor::A) {
|
match board.measure_moisture_hz(plant_id, plant_hal::Sensor::A) {
|
||||||
Ok(raw) => {
|
Ok(raw) => match map_range_moisture(raw) {
|
||||||
match map_range_moisture(raw) {
|
Ok(moisture_percent) => HumiditySensorState::HumidityValue {
|
||||||
Ok(moisture_percent) => HumiditySensorState::HumidityValue { raw_hz: raw, moisture_percent },
|
raw_hz: raw,
|
||||||
|
moisture_percent,
|
||||||
|
},
|
||||||
Err(err) => HumiditySensorState::SensorError(err),
|
Err(err) => HumiditySensorState::SensorError(err),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Err(err) => HumiditySensorState::BoardError(err.to_string()),
|
Err(err) => HumiditySensorState::BoardError(err.to_string()),
|
||||||
}
|
}
|
||||||
@ -71,11 +115,12 @@ impl PlantState {
|
|||||||
};
|
};
|
||||||
let sensor_b = if config.sensor_b {
|
let sensor_b = if config.sensor_b {
|
||||||
match board.measure_moisture_hz(plant_id, plant_hal::Sensor::B) {
|
match board.measure_moisture_hz(plant_id, plant_hal::Sensor::B) {
|
||||||
Ok(raw) => {
|
Ok(raw) => match map_range_moisture(raw) {
|
||||||
match map_range_moisture(raw) {
|
Ok(moisture_percent) => HumiditySensorState::HumidityValue {
|
||||||
Ok(moisture_percent) => HumiditySensorState::HumidityValue { raw_hz: raw, moisture_percent },
|
raw_hz: raw,
|
||||||
|
moisture_percent,
|
||||||
|
},
|
||||||
Err(err) => HumiditySensorState::SensorError(err),
|
Err(err) => HumiditySensorState::SensorError(err),
|
||||||
}
|
|
||||||
},
|
},
|
||||||
Err(err) => HumiditySensorState::BoardError(err.to_string()),
|
Err(err) => HumiditySensorState::BoardError(err.to_string()),
|
||||||
}
|
}
|
||||||
@ -84,45 +129,145 @@ impl PlantState {
|
|||||||
};
|
};
|
||||||
let previous_pump = board.last_pump_time(plant_id);
|
let previous_pump = board.last_pump_time(plant_id);
|
||||||
let consecutive_pump_count = board.consecutive_pump_count(plant_id);
|
let consecutive_pump_count = board.consecutive_pump_count(plant_id);
|
||||||
Self {
|
let state = Self {
|
||||||
sensor_a,
|
sensor_a,
|
||||||
sensor_b,
|
sensor_b,
|
||||||
pump: PumpState { consecutive_pump_count , previous_pump}
|
pump: PumpState {
|
||||||
|
consecutive_pump_count,
|
||||||
|
previous_pump,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if state.is_err() {
|
||||||
|
board.fault(plant_id, true);
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pump_in_timeout(&self, plant_conf: &PlantConfig, current_time: &DateTime<Tz>) -> bool {
|
||||||
|
self.pump.previous_pump.is_some_and(|last_pump| {
|
||||||
|
last_pump
|
||||||
|
.checked_add_signed(TimeDelta::minutes(plant_conf.pump_cooldown_min.into()))
|
||||||
|
.is_some_and(|earliest_next_allowed_pump| {
|
||||||
|
earliest_next_allowed_pump > *current_time
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_err(&self) -> bool {
|
||||||
|
self.sensor_a.is_err() || self.sensor_b.is_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn needs_to_be_watered(
|
||||||
|
&self,
|
||||||
|
plant_conf: &PlantConfig,
|
||||||
|
current_time: &DateTime<Tz>,
|
||||||
|
) -> bool {
|
||||||
|
match plant_conf.mode {
|
||||||
|
PlantWateringMode::OFF => false,
|
||||||
|
PlantWateringMode::TargetMoisture => {
|
||||||
|
let moisture_percent = match (
|
||||||
|
self.sensor_a.moisture_percent(),
|
||||||
|
self.sensor_b.moisture_percent(),
|
||||||
|
) {
|
||||||
|
(Some(moisture_a), Some(moisture_b)) => (moisture_a + moisture_b) / 2.,
|
||||||
|
(Some(moisture_percent), _) => moisture_percent,
|
||||||
|
(_, Some(moisture_percent)) => moisture_percent,
|
||||||
|
_ => {
|
||||||
|
// Case for both sensors hitting an error do not water plant in this case
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if self.pump_in_timeout(plant_conf, current_time) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
if moisture_percent < plant_conf.target_moisture {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlantWateringMode::TimerOnly => {
|
||||||
|
if self.pump_in_timeout(plant_conf, current_time) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Default, Serialize)]
|
//fn update_plant_state(
|
||||||
|
// plantstate: &mut [PlantInfo; PLANT_COUNT],
|
||||||
|
// board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>,
|
||||||
|
// config: &PlantControllerConfig,
|
||||||
|
//) {
|
||||||
|
// for plant in 0..PLANT_COUNT {
|
||||||
|
// let state = &plantstate[plant];
|
||||||
|
// let plant_config = &config.plants[plant];
|
||||||
|
//
|
||||||
|
// let mode = format!("{:?}", plant_config.mode);
|
||||||
|
//
|
||||||
|
// let plant_dto = PlantStateMQTT {
|
||||||
|
// a: &sensor_to_string(
|
||||||
|
// &state.a,
|
||||||
|
// &state.sensor_error_a,
|
||||||
|
// plant_config.mode != PlantWateringMode::OFF,
|
||||||
|
// ),
|
||||||
|
// a_raw: &state.a_raw.unwrap_or(0).to_string(),
|
||||||
|
// b: &sensor_to_string(&state.b, &state.sensor_error_b, plant_config.sensor_b),
|
||||||
|
// b_raw: &state.b_raw.unwrap_or(0).to_string(),
|
||||||
|
// active: state.active,
|
||||||
|
// mode: &mode,
|
||||||
|
// last_pump: &time_to_string_utc(board.last_pump_time(plant)),
|
||||||
|
// next_pump: &time_to_string(state.next_pump),
|
||||||
|
// consecutive_pump_count: state.consecutive_pump_count,
|
||||||
|
// cooldown: state.cooldown,
|
||||||
|
// dry: state.dry,
|
||||||
|
// not_effective: state.not_effective,
|
||||||
|
// out_of_work_hour: state.out_of_work_hour,
|
||||||
|
// pump_error: state.pump_error,
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// match serde_json::to_string(&plant_dto) {
|
||||||
|
// Ok(state) => {
|
||||||
|
// let plant_topic = format!("/plant{}", plant + 1);
|
||||||
|
// let _ = board.mqtt_publish(&config, &plant_topic, state.as_bytes());
|
||||||
|
// //reduce speed as else messages will be dropped
|
||||||
|
// Delay::new_default().delay_ms(200);
|
||||||
|
// }
|
||||||
|
// Err(err) => {
|
||||||
|
// println!("Error publishing lightstate {}", err);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Serialize)]
|
||||||
/// State of a single plant to be tracked
|
/// State of a single plant to be tracked
|
||||||
pub struct PlantInfo {
|
pub struct PlantInfo {
|
||||||
/// state of humidity sensor on bank a
|
/// state of humidity sensor on bank a
|
||||||
a: HumiditySensorState,
|
sensor_a: HumiditySensorState,
|
||||||
/// raw measured frequency value for sensor on bank a in hertz
|
|
||||||
a_raw: Option<u32>,
|
|
||||||
/// state of humidity sensor on bank b
|
/// state of humidity sensor on bank b
|
||||||
b: HumiditySensorState,
|
sensor_b: HumiditySensorState,
|
||||||
/// raw measured frequency value for sensor on bank b in hertz
|
|
||||||
b_raw: Option<u32>,
|
|
||||||
/// configured plant watering mode
|
/// configured plant watering mode
|
||||||
mode: config::Mode,
|
mode: PlantWateringMode,
|
||||||
/// how often has the logic determined that plant should have been irrigated but wasn't
|
|
||||||
consecutive_pump_count: u32,
|
|
||||||
/// plant needs to be watered
|
/// plant needs to be watered
|
||||||
do_water: bool,
|
do_water: bool,
|
||||||
/// is plant considerd to be dry according to settings
|
/// is plant considerd to be dry according to settings
|
||||||
dry: bool,
|
dry: bool,
|
||||||
/// is pump currently running
|
|
||||||
active: bool,
|
|
||||||
/// TODO: convert this to an Option<PumpErorr> enum for every case that can happen
|
|
||||||
pump_error: bool,
|
|
||||||
/// if pump count has increased higher than configured limit
|
|
||||||
not_effective: bool,
|
|
||||||
/// plant irrigation cooldown is active
|
/// plant irrigation cooldown is active
|
||||||
cooldown: bool,
|
cooldown: bool,
|
||||||
/// we want to irrigate but tank is empty
|
/// we want to irrigate but tank is empty
|
||||||
no_water: bool,
|
no_water: bool,
|
||||||
/// pump should not be watered at this time of day
|
/// plant should not be watered at this time of day TODO: does this really belong here? Isn't this a global setting?
|
||||||
out_of_work_hour: bool,
|
out_of_work_hour: bool,
|
||||||
|
/// is pump currently running
|
||||||
|
active: bool,
|
||||||
|
/// how often has the logic determined that plant should have been irrigated but wasn't
|
||||||
|
consecutive_pump_count: u32,
|
||||||
|
pump_error: Option<PumpError>,
|
||||||
/// last time when pump was active
|
/// last time when pump was active
|
||||||
last_pump: Option<DateTime<Tz>>,
|
last_pump: Option<DateTime<Tz>>,
|
||||||
/// next time when pump should activate
|
/// next time when pump should activate
|
||||||
|
10
rust/src/util.rs
Normal file
10
rust/src/util.rs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
pub trait LimitPrecision {
|
||||||
|
fn to_precision(self, presision: i32) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LimitPrecision for f32 {
|
||||||
|
fn to_precision(self, precision: i32) -> Self {
|
||||||
|
(self * (10_f32).powi(precision)).round() / (10_f32).powi(precision)
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
//offer ota and config mode
|
//offer ota and config mode
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
determine_tank_state, get_version, log::LogMessage, map_range_moisture, plant_hal::PLANT_COUNT,
|
determine_tank_state, get_version, log::LogMessage, plant_hal::PLANT_COUNT,
|
||||||
BOARD_ACCESS,
|
plant_state::PlantState, util::LimitPrecision, BOARD_ACCESS,
|
||||||
};
|
};
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
@ -35,8 +35,8 @@ struct LoadData<'a> {
|
|||||||
|
|
||||||
#[derive(Serialize, Debug)]
|
#[derive(Serialize, Debug)]
|
||||||
struct Moistures {
|
struct Moistures {
|
||||||
moisture_a: Vec<u8>,
|
moisture_a: Vec<Option<f32>>,
|
||||||
moisture_b: Vec<u8>,
|
moisture_b: Vec<Option<f32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
@ -81,33 +81,21 @@ fn get_live_moisture(
|
|||||||
_request: &mut Request<&mut EspHttpConnection>,
|
_request: &mut Request<&mut EspHttpConnection>,
|
||||||
) -> Result<Option<std::string::String>, anyhow::Error> {
|
) -> Result<Option<std::string::String>, anyhow::Error> {
|
||||||
let mut board = BOARD_ACCESS.lock().unwrap();
|
let mut board = BOARD_ACCESS.lock().unwrap();
|
||||||
|
let config = board.get_config().unwrap();
|
||||||
|
|
||||||
let mut a: Vec<u8> = Vec::new();
|
let plant_state = Vec::from_iter(
|
||||||
let mut b: Vec<u8> = Vec::new();
|
(0..PLANT_COUNT).map(|i| PlantState::read_hardware_state(i, &mut board, &config.plants[i])),
|
||||||
for plant in 0..8 {
|
);
|
||||||
let a_hz = board.measure_moisture_hz(plant, crate::plant_hal::Sensor::A)?;
|
let a = Vec::from_iter(
|
||||||
let b_hz = board.measure_moisture_hz(plant, crate::plant_hal::Sensor::B)?;
|
plant_state
|
||||||
let a_pct = map_range_moisture(a_hz as f32);
|
.iter()
|
||||||
|
.map(|s| s.sensor_a.moisture_percent().map(|f| f.to_precision(2))),
|
||||||
match a_pct {
|
);
|
||||||
Ok(result) => {
|
let b = Vec::from_iter(
|
||||||
a.push(result);
|
plant_state
|
||||||
}
|
.iter()
|
||||||
Err(_) => {
|
.map(|s| s.sensor_b.moisture_percent().map(|f| f.to_precision(2))),
|
||||||
a.push(200);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let b_pct = map_range_moisture(b_hz as f32);
|
|
||||||
match b_pct {
|
|
||||||
Ok(result) => {
|
|
||||||
b.push(result);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
b.push(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = Moistures {
|
let data = Moistures {
|
||||||
moisture_a: a,
|
moisture_a: a,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user