main pump handler
This commit is contained in:
parent
b9ec3247af
commit
b5b5b25238
@ -14,8 +14,8 @@ pub struct Config {
|
||||
pub tank_sensor_enabled: bool,
|
||||
pub tank_useable_ml: u32,
|
||||
pub tank_warn_percent: u8,
|
||||
pub tank_empty_mv: f32,
|
||||
pub tank_full_mv: f32,
|
||||
pub tank_empty_mv: u16,
|
||||
pub tank_full_mv: u16,
|
||||
|
||||
pub night_lamp_hour_start: u8,
|
||||
pub night_lamp_hour_end: u8,
|
||||
@ -38,8 +38,8 @@ impl Default for Config {
|
||||
plants: [Plant::default(); PLANT_COUNT],
|
||||
max_consecutive_pump_count: 15,
|
||||
tank_useable_ml: 5000,
|
||||
tank_empty_mv: 0.1,
|
||||
tank_full_mv: 3.3,
|
||||
tank_empty_mv: 0100_u16,
|
||||
tank_full_mv: 3300_u16,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,7 @@ pub enum Mode {
|
||||
OFF,
|
||||
TargetMoisture,
|
||||
TimerOnly,
|
||||
TimerAndDeadzone,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
|
||||
|
341
rust/src/main.rs
341
rust/src/main.rs
@ -3,11 +3,11 @@ use std::{
|
||||
sync::{atomic::AtomicBool, Arc, Mutex},
|
||||
};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{Datelike, Duration, NaiveDateTime, Timelike};
|
||||
use chrono_tz::Europe::Berlin;
|
||||
use anyhow::{Result, bail};
|
||||
use chrono::{Datelike, Duration, NaiveDateTime, Timelike, DateTime};
|
||||
use chrono_tz::{Europe::Berlin, Tz};
|
||||
use esp_idf_hal::delay::Delay;
|
||||
use esp_idf_sys::{esp_restart, vTaskDelay};
|
||||
use esp_idf_sys::{esp_restart, vTaskDelay, CONFIG_FREERTOS_HZ, esp_deep_sleep};
|
||||
use esp_ota::rollback_and_reboot;
|
||||
use log::error;
|
||||
use once_cell::sync::Lazy;
|
||||
@ -51,16 +51,21 @@ enum WaitType {
|
||||
|
||||
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Default)]
|
||||
struct PlantState {
|
||||
a: u8,
|
||||
b: u8,
|
||||
p: u8,
|
||||
after_p: u8,
|
||||
a: Option<u8>,
|
||||
b: Option<u8>,
|
||||
p: Option<u8>,
|
||||
after_p: Option<u8>,
|
||||
do_water: bool,
|
||||
dry: bool,
|
||||
active: bool,
|
||||
pump_error: bool,
|
||||
not_effective: bool,
|
||||
cooldown: bool,
|
||||
no_water: bool,
|
||||
sensor_error_a: bool,
|
||||
sensor_error_b: bool,
|
||||
sensor_error_p: bool,
|
||||
out_of_work_hour: bool
|
||||
}
|
||||
|
||||
fn wait_infinity(wait_type: WaitType, reboot_now: Arc<AtomicBool>) -> ! {
|
||||
@ -91,7 +96,9 @@ fn wait_infinity(wait_type: WaitType, reboot_now: Arc<AtomicBool>) -> ! {
|
||||
vTaskDelay(delay);
|
||||
if wait_type == WaitType::StayAlive
|
||||
&& !STAY_ALIVE.load(std::sync::atomic::Ordering::Relaxed)
|
||||
{}
|
||||
{
|
||||
reboot_now.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
if reboot_now.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
println!("Rebooting");
|
||||
esp_restart();
|
||||
@ -103,8 +110,131 @@ fn wait_infinity(wait_type: WaitType, reboot_now: Arc<AtomicBool>) -> ! {
|
||||
pub static BOARD_ACCESS: Lazy<Mutex<PlantCtrlBoard>> = Lazy::new(|| PlantHal::create().unwrap());
|
||||
pub static STAY_ALIVE: Lazy<AtomicBool> = Lazy::new(|| AtomicBool::new(false));
|
||||
|
||||
fn map_range(from_range: (f32, f32), to_range: (f32, f32), s: f32) -> f32 {
|
||||
to_range.0 + (s - from_range.0) * (to_range.1 - to_range.0) / (from_range.1 - from_range.0)
|
||||
fn map_range(from_range: (f32, f32), s: f32) -> Result<f32> {
|
||||
if s < from_range.0 {
|
||||
bail!("Value out of range, min {} but current is {}", from_range.0, s);
|
||||
}
|
||||
if s > from_range.1 {
|
||||
bail!("Value out of range, max {} but current is {}", from_range.1, s);
|
||||
}
|
||||
return Ok(TO.0 + (s - from_range.0) * (TO.1 - TO.0) / (from_range.1 - from_range.0));
|
||||
}
|
||||
|
||||
fn map_range_moisture(s: f32) -> Result<u8> {
|
||||
if s < FROM.0 {
|
||||
bail!("Value out of range, min {} but current is {}", FROM.0, s);
|
||||
}
|
||||
if s > FROM.1 {
|
||||
bail!("Value out of range, max {} but current is {}", FROM.1, s);
|
||||
}
|
||||
let tmp = TO.0 + (s - FROM.0) * (TO.1 - TO.0) / (FROM.1 - FROM.0);
|
||||
return Ok(tmp as u8);
|
||||
}
|
||||
|
||||
fn in_time_range(cur: DateTime<Tz>, start:u8, end:u8) -> bool{
|
||||
let curhour = cur.hour() as u8;
|
||||
//eg 10-14
|
||||
if(start < end){
|
||||
return curhour > start && curhour < end;
|
||||
} else {
|
||||
//eg 20-05
|
||||
return curhour > start || curhour < end;;
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_next_plant(plantstate: &mut [PlantState;PLANT_COUNT],cur: DateTime<Tz>, enough_water: bool, tank_sensor_error: bool, config: &Config, board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>) -> Option<usize> {
|
||||
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 => {
|
||||
match board.measure_moisture_hz(plant, plant_hal::Sensor::A).and_then (|moist| map_range_moisture(moist as f32)) {
|
||||
Ok(a) => state.a = Some(a),
|
||||
Err(err) => {
|
||||
board.fault(plant, true);
|
||||
println!("Could not determine Moisture A for plant {} due to {}", plant, err);
|
||||
state.a = None;
|
||||
state.sensor_error_a = true;
|
||||
}
|
||||
}
|
||||
match board.measure_moisture_hz(plant, plant_hal::Sensor::B).and_then (|moist| map_range_moisture(moist as f32)) {
|
||||
Ok(b) => state.b = Some(b),
|
||||
Err(err) => {
|
||||
board.fault(plant, true);
|
||||
println!("Could not determine Moisture B for plant {} due to {}", plant, err);
|
||||
state.b = None;
|
||||
state.sensor_error_b = true;
|
||||
}
|
||||
}
|
||||
//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;
|
||||
if tank_sensor_error && !config.tank_allow_pumping_if_sensor_error || !enough_water {
|
||||
state.no_water = true;
|
||||
}
|
||||
}
|
||||
let duration = Duration::minutes((60 * plant_config.pump_cooldown_min).into());
|
||||
let next_pump = board.last_pump_time(plant) + duration;
|
||||
if next_pump > cur {
|
||||
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.dry && !state.no_water && !state.cooldown && !state.out_of_work_hour){
|
||||
state.do_water = true;
|
||||
}
|
||||
},
|
||||
config::Mode::TimerOnly => {
|
||||
let duration = Duration::minutes((60 * plant_config.pump_cooldown_min).into());
|
||||
let next_pump = board.last_pump_time(plant) + duration;
|
||||
if next_pump > cur {
|
||||
state.cooldown = true;
|
||||
} else {
|
||||
state.do_water = true;
|
||||
}
|
||||
},
|
||||
config::Mode::TimerAndDeadzone => {
|
||||
let duration = Duration::minutes((60 * plant_config.pump_cooldown_min).into());
|
||||
let next_pump = board.last_pump_time(plant) + duration;
|
||||
if next_pump > cur {
|
||||
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){
|
||||
state.do_water = true;
|
||||
}
|
||||
},
|
||||
}
|
||||
//FIXME publish state here!
|
||||
if state.do_water{
|
||||
if board.consecutive_pump_count(plant) > config.max_consecutive_pump_count.into() {
|
||||
state.not_effective = true;
|
||||
board.fault(plant, true);
|
||||
}
|
||||
} else {
|
||||
board.store_consecutive_pump_count(plant, 0);
|
||||
}
|
||||
println!("Plant {} state is {:?}", plant, state);
|
||||
}
|
||||
for plant in 0..PLANT_COUNT {
|
||||
let state = &plantstate[plant];
|
||||
println!("Checking for water plant {} with state {}", plant, state.do_water);
|
||||
if state.do_water {
|
||||
return Some(plant);
|
||||
}
|
||||
}
|
||||
println!("No plant needs water");
|
||||
return None
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@ -129,8 +259,8 @@ fn main() -> Result<()> {
|
||||
println!("Version useing git has {}", git_hash);
|
||||
|
||||
let mut partition_state: embedded_svc::ota::SlotState = embedded_svc::ota::SlotState::Unknown;
|
||||
// match esp_idf_svc::ota::EspOta::new() {
|
||||
// Ok(ota) => {
|
||||
match esp_idf_svc::ota::EspOta::new() {
|
||||
Ok(ota) => {
|
||||
// match ota.get_running_slot(){
|
||||
// Ok(slot) => {
|
||||
// partition_state = slot.state;
|
||||
@ -143,14 +273,15 @@ fn main() -> Result<()> {
|
||||
// println!("Error getting running slot {}", err);
|
||||
// },
|
||||
// }
|
||||
// },
|
||||
// Err(err) => {
|
||||
// println!("Error obtaining ota info {}", err);
|
||||
// },
|
||||
// }
|
||||
},
|
||||
Err(err) => {
|
||||
println!("Error obtaining ota info {}", err);
|
||||
},
|
||||
}
|
||||
|
||||
println!("Board hal init");
|
||||
let mut board: std::sync::MutexGuard<'_, PlantCtrlBoard<'_>> = BOARD_ACCESS.lock().unwrap();
|
||||
board.disable_all()?;
|
||||
println!("Mounting filesystem");
|
||||
board.mount_file_system()?;
|
||||
let free_space = board.file_system_size()?;
|
||||
@ -257,11 +388,12 @@ fn main() -> Result<()> {
|
||||
board.general_fault(true);
|
||||
}
|
||||
}
|
||||
println!("Running logic at utc {}", cur);
|
||||
let europe_time = cur.with_timezone(&Berlin);
|
||||
println!("Running logic at europe/berlin {}", europe_time);
|
||||
}
|
||||
|
||||
println!("Running logic at utc {}", cur);
|
||||
let europe_time = cur.with_timezone(&Berlin);
|
||||
println!("Running logic at europe/berlin {}", europe_time);
|
||||
|
||||
let config: Config;
|
||||
match board.get_config() {
|
||||
Ok(valid) => {
|
||||
@ -298,92 +430,66 @@ fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
let mut enough_water = true;
|
||||
let mut tank_sensor_error = false;
|
||||
if config.tank_sensor_enabled {
|
||||
let tank_value = board.tank_sensor_mv();
|
||||
match tank_value {
|
||||
Ok(tank_raw) => {
|
||||
//FIXME clear
|
||||
let percent = map_range(
|
||||
(config.tank_empty_mv, config.tank_full_mv),
|
||||
(0_f32, 100_f32),
|
||||
tank_raw.into(),
|
||||
);
|
||||
let left_ml = ((percent / 100_f32) * config.tank_useable_ml as f32) as u32;
|
||||
println!(
|
||||
"Tank sensor returned mv {} as {}% leaving {} ml useable",
|
||||
tank_raw, percent as u8, left_ml
|
||||
);
|
||||
if config.tank_warn_percent > percent as u8 {
|
||||
board.general_fault(true);
|
||||
println!(
|
||||
"Low water, current percent is {}, minimum warn level is {}",
|
||||
percent as u8, config.tank_warn_percent
|
||||
);
|
||||
//FIXME warn here
|
||||
}
|
||||
if config.tank_warn_percent <= 0 {
|
||||
enough_water = false;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let mut tank_value_r = 0;
|
||||
|
||||
let success = board.tank_sensor_mv().and_then(|raw| {
|
||||
tank_value_r = raw;
|
||||
return map_range(
|
||||
(config.tank_empty_mv as f32, config.tank_full_mv as f32),
|
||||
raw as f32,
|
||||
);
|
||||
}).and_then(|percent| {
|
||||
let left_ml = ((percent / 100_f32) * config.tank_useable_ml as f32) as u32;
|
||||
println!(
|
||||
"Tank sensor returned mv {} as {}% leaving {} ml useable",
|
||||
tank_value_r, percent as u8, left_ml
|
||||
);
|
||||
if config.tank_warn_percent > percent as u8 {
|
||||
board.general_fault(true);
|
||||
if !config.tank_allow_pumping_if_sensor_error {
|
||||
enough_water = false;
|
||||
}
|
||||
//set tank sensor state to fault
|
||||
println!(
|
||||
"Low water, current percent is {}, minimum warn level is {}",
|
||||
percent as u8, config.tank_warn_percent
|
||||
);
|
||||
}
|
||||
if config.tank_warn_percent <= 0 {
|
||||
enough_water = false;
|
||||
}
|
||||
return Ok(());
|
||||
});
|
||||
match success {
|
||||
Err(err) => {
|
||||
println!("Could not determine tank value due to {}", err);
|
||||
board.general_fault(true);
|
||||
tank_sensor_error = true;
|
||||
}
|
||||
Ok(_) => {},
|
||||
}
|
||||
}
|
||||
|
||||
let plantstate = [PlantState {
|
||||
let mut water_frozen = false;
|
||||
for _attempt in 0..5 {
|
||||
let water_temperature = board.water_temperature_c();
|
||||
match water_temperature {
|
||||
Ok(temp) => {
|
||||
//FIXME mqtt here
|
||||
println!("Water temp is {}", temp);
|
||||
if(temp < 4_f32){
|
||||
water_frozen = true;
|
||||
}
|
||||
break;
|
||||
},
|
||||
Err(err) => {
|
||||
println!("Could not get water temp {}", err)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let mut plantstate = [PlantState {
|
||||
..Default::default()
|
||||
}; PLANT_COUNT];
|
||||
for plant in 0..PLANT_COUNT {
|
||||
let mut state = plantstate[plant];
|
||||
//return mapf(mMoisture_raw.getMedian(), MOIST_SENSOR_MIN_FRQ, MOIST_SENSOR_MAX_FRQ, 0, 100);
|
||||
state.a = map_range(
|
||||
FROM,
|
||||
TO,
|
||||
board.measure_moisture_hz(plant, plant_hal::Sensor::A)? as f32,
|
||||
) as u8;
|
||||
state.b = map_range(
|
||||
FROM,
|
||||
TO,
|
||||
board.measure_moisture_hz(plant, plant_hal::Sensor::B)? as f32,
|
||||
) as u8;
|
||||
state.p = map_range(
|
||||
FROM,
|
||||
TO,
|
||||
board.measure_moisture_hz(plant, plant_hal::Sensor::PUMP)? as f32,
|
||||
) as u8;
|
||||
let plant_config = config.plants[plant];
|
||||
|
||||
//FIXME how to average analyze whatever?
|
||||
if state.a < plant_config.target_moisture || state.b < plant_config.target_moisture {
|
||||
state.dry = true;
|
||||
if !enough_water {
|
||||
state.no_water = true;
|
||||
}
|
||||
}
|
||||
|
||||
let duration = Duration::minutes((60 * plant_config.pump_cooldown_min).into());
|
||||
if (board.last_pump_time(plant)? + duration) > cur {
|
||||
state.cooldown = true;
|
||||
}
|
||||
|
||||
if state.dry {
|
||||
let consecutive_pump_count = board.consecutive_pump_count(plant) + 1;
|
||||
board.store_consecutive_pump_count(plant, consecutive_pump_count);
|
||||
if consecutive_pump_count > config.max_consecutive_pump_count.into() {
|
||||
state.not_effective = true;
|
||||
board.fault(plant, true);
|
||||
}
|
||||
} else {
|
||||
board.store_consecutive_pump_count(plant, 0);
|
||||
}
|
||||
|
||||
//TODO update mqtt state here!
|
||||
}
|
||||
let plant_to_pump = determine_next_plant(&mut plantstate, europe_time, enough_water, tank_sensor_error, &config, &mut board);
|
||||
|
||||
if STAY_ALIVE.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
drop(board);
|
||||
@ -392,29 +498,39 @@ fn main() -> Result<()> {
|
||||
wait_infinity(WaitType::StayAlive, reboot_now.clone());
|
||||
}
|
||||
|
||||
'eachplant: for plant in 0..PLANT_COUNT {
|
||||
let mut state = plantstate[plant];
|
||||
if state.dry && !state.cooldown {
|
||||
println!("Trying to pump with pump {} now", plant);
|
||||
match plant_to_pump {
|
||||
Some(plant) => {
|
||||
let mut state = plantstate[plant];
|
||||
let consecutive_pump_count = board.consecutive_pump_count(plant) + 1;
|
||||
board.store_consecutive_pump_count(plant, consecutive_pump_count);
|
||||
let plant_config = config.plants[plant];
|
||||
println!("Trying to pump for {}s with pump {} now", plant_config.pump_time_s,plant);
|
||||
|
||||
board.any_pump(true)?;
|
||||
board.store_last_pump_time(plant, cur);
|
||||
board.pump(plant, true)?;
|
||||
board.last_pump_time(plant)?;
|
||||
board.last_pump_time(plant);
|
||||
state.active = true;
|
||||
unsafe { vTaskDelay(plant_config.pump_time_s.into()) };
|
||||
state.after_p = map_range(
|
||||
FROM,
|
||||
TO,
|
||||
board.measure_moisture_hz(plant, plant_hal::Sensor::PUMP)? as f32,
|
||||
) as u8;
|
||||
if state.after_p < state.p + 5 {
|
||||
//FIXME do periodic pump test here and state update
|
||||
unsafe { vTaskDelay(plant_config.pump_time_s as u32*CONFIG_FREERTOS_HZ) };
|
||||
match map_range_moisture(board.measure_moisture_hz(plant, plant_hal::Sensor::PUMP)? as f32) {
|
||||
Ok(p) => state.after_p = Some(p),
|
||||
Err(err) => {
|
||||
board.fault(plant, true);
|
||||
println!("Could not determine Moisture P after for plant {} due to {}", plant, err);
|
||||
state.after_p = None;
|
||||
state.sensor_error_p = true;
|
||||
}
|
||||
}
|
||||
if state.after_p.is_none() || state.p.is_none() || state.after_p.unwrap() < state.p.unwrap() + 5 {
|
||||
state.pump_error = true;
|
||||
board.fault(plant, true);
|
||||
}
|
||||
break 'eachplant;
|
||||
},
|
||||
None => {
|
||||
println!("Nothing to do");
|
||||
}
|
||||
,
|
||||
}
|
||||
|
||||
/*
|
||||
@ -437,6 +553,7 @@ fn main() -> Result<()> {
|
||||
}
|
||||
*/
|
||||
//deepsleep here?
|
||||
unsafe { esp_deep_sleep(1000*1000*10) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -143,7 +143,7 @@ pub trait PlantCtrlBoardInteraction {
|
||||
|
||||
fn measure_moisture_hz(&self, plant: usize, sensor: Sensor) -> Result<i32>;
|
||||
fn pump(&self, plant: usize, enable: bool) -> Result<()>;
|
||||
fn last_pump_time(&self, plant: usize) -> Result<chrono::DateTime<Utc>>;
|
||||
fn last_pump_time(&self, plant: usize) -> chrono::DateTime<Utc>;
|
||||
fn store_last_pump_time(&mut self, plant: usize, time: chrono::DateTime<Utc>);
|
||||
fn store_consecutive_pump_count(&mut self, plant: usize, count: u32);
|
||||
fn consecutive_pump_count(&mut self, plant: usize) -> u32;
|
||||
@ -163,6 +163,7 @@ pub trait PlantCtrlBoardInteraction {
|
||||
fn test(&mut self) -> Result<()>;
|
||||
fn is_wifi_config_file_existant(&mut self) -> bool;
|
||||
fn mqtt(&mut self, config: &Config) -> Result<()>;
|
||||
fn disable_all(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
pub trait CreatePlantHal<'a> {
|
||||
@ -230,7 +231,7 @@ impl PlantCtrlBoardInteraction for PlantCtrlBoard<'_> {
|
||||
if sensor_data.temperature == 85_f32 {
|
||||
bail!("Ds18b20 dummy temperature returned");
|
||||
}
|
||||
Ok(sensor_data.temperature)
|
||||
Ok(sensor_data.temperature/10_f32)
|
||||
}
|
||||
|
||||
fn tank_sensor_mv(&mut self) -> Result<u16> {
|
||||
@ -265,11 +266,10 @@ impl PlantCtrlBoardInteraction for PlantCtrlBoard<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn last_pump_time(&self, plant: usize) -> Result<chrono::DateTime<Utc>> {
|
||||
fn last_pump_time(&self, plant: usize) -> chrono::DateTime<Utc> {
|
||||
let ts = unsafe { LAST_WATERING_TIMESTAMP }[plant];
|
||||
let timestamp = NaiveDateTime::from_timestamp_millis(ts)
|
||||
.ok_or(anyhow!("could not convert timestamp"))?;
|
||||
Ok(DateTime::<Utc>::from_naive_utc_and_offset(timestamp, Utc))
|
||||
let timestamp = NaiveDateTime::from_timestamp_millis(ts).unwrap();
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(timestamp, Utc)
|
||||
}
|
||||
|
||||
fn store_last_pump_time(&mut self, plant: usize, time: chrono::DateTime<Utc>) {
|
||||
@ -660,6 +660,15 @@ impl PlantCtrlBoardInteraction for PlantCtrlBoard<'_> {
|
||||
}
|
||||
bail!("Mqtt did not complete roundtrip in time");
|
||||
}
|
||||
|
||||
fn disable_all(&mut self) -> Result<()> {
|
||||
for mut pin in self.shift_register.decompose() {
|
||||
pin.set_low().unwrap();
|
||||
}
|
||||
self.general_fault(false);
|
||||
self.any_pump(false)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
impl CreatePlantHal<'_> for PlantHal {
|
||||
|
Loading…
Reference in New Issue
Block a user