more stack for json, more json for mqtt
This commit is contained in:
		| @@ -1,5 +1,5 @@ | ||||
| # Rust often needs a bit of an extra main task stack size compared to C (the default is 3K) | ||||
| CONFIG_ESP_MAIN_TASK_STACK_SIZE=25000 | ||||
| CONFIG_ESP_MAIN_TASK_STACK_SIZE=50000 | ||||
|  | ||||
| # Use this to set FreeRTOS kernel tick frequency to 1000 Hz (100 Hz by default). | ||||
| # This allows to use 1 ms granuality for thread sleeps (10 ms by default). | ||||
|   | ||||
							
								
								
									
										332
									
								
								rust/src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										332
									
								
								rust/src/main.rs
									
									
									
									
									
								
							| @@ -46,7 +46,7 @@ mod webserver { | ||||
|     pub mod webserver; | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)] | ||||
| #[derive(Serialize, Deserialize, Debug, PartialEq)] | ||||
| enum OnlineMode { | ||||
|     Offline, | ||||
|     Wifi, | ||||
| @@ -54,7 +54,7 @@ enum OnlineMode { | ||||
|     Online, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)] | ||||
| #[derive(Serialize, Deserialize, Debug, PartialEq)] | ||||
| enum WaitType { | ||||
|     InitialConfig, | ||||
|     FlashError, | ||||
| @@ -62,7 +62,7 @@ enum WaitType { | ||||
|     StayAlive, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Default)] | ||||
| #[derive(Serialize, Deserialize, Debug, PartialEq, Default)] | ||||
| struct LightState { | ||||
|     active: bool, | ||||
|     out_of_work_hour: bool, | ||||
| @@ -70,7 +70,7 @@ struct LightState { | ||||
|     is_day: bool, | ||||
| } | ||||
|  | ||||
| #[derive(Clone, Copy, Debug, PartialEq, Default)] | ||||
| #[derive(Debug, PartialEq, Default)] | ||||
| struct PlantState { | ||||
|     a: Option<u8>, | ||||
|     b: Option<u8>, | ||||
| @@ -91,14 +91,14 @@ struct PlantState { | ||||
|     next_pump: Option<DateTime<Tz>>, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)] | ||||
| #[derive(Serialize, Deserialize, Debug, PartialEq)] | ||||
| enum SensorError { | ||||
|     Unknown, | ||||
|     ShortCircuit { hz: f32, max: f32 }, | ||||
|     OpenCircuit { hz: f32, min: f32 }, | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, PartialEq, Default)] | ||||
| #[derive(Debug, PartialEq, Default)] | ||||
| struct TankState { | ||||
|     enough_water: bool, | ||||
|     warn_level: bool, | ||||
| @@ -113,7 +113,7 @@ struct TankStateMQTT { | ||||
|     left_ml: u32, | ||||
|     sensor_error: bool, | ||||
|     raw: u16, | ||||
|     water_frozen: String | ||||
|     water_frozen: String, | ||||
| } | ||||
|  | ||||
| #[derive(Serialize)] | ||||
| @@ -365,13 +365,13 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|     } | ||||
|  | ||||
|     let tank_state = determine_tank_state(&mut board, &config); | ||||
|     let mut tank_state_mqtt = TankStateMQTT{ | ||||
|         enough_water : tank_state.enough_water, | ||||
|         left_ml : tank_state.left_ml, | ||||
|         warn_level : tank_state.warn_level, | ||||
|     let mut tank_state_mqtt = TankStateMQTT { | ||||
|         enough_water: tank_state.enough_water, | ||||
|         left_ml: tank_state.left_ml, | ||||
|         warn_level: tank_state.warn_level, | ||||
|         sensor_error: tank_state.sensor_error, | ||||
|         raw: tank_state.raw, | ||||
|         water_frozen: "".to_owned() | ||||
|         water_frozen: "".to_owned(), | ||||
|     }; | ||||
|     let mut water_frozen = false; | ||||
|  | ||||
| @@ -396,9 +396,7 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|             } | ||||
|             tank_state_mqtt.water_frozen = water_frozen.to_string(); | ||||
|         } | ||||
|         None => { | ||||
|             tank_state_mqtt.water_frozen = "tank sensor error".to_owned() | ||||
|         } | ||||
|         None => tank_state_mqtt.water_frozen = "tank sensor error".to_owned(), | ||||
|     } | ||||
|  | ||||
|     if online_mode == OnlineMode::Online { | ||||
| @@ -412,9 +410,9 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     let mut plantstate = [PlantState { | ||||
|     let mut plantstate: [PlantState; PLANT_COUNT] = core::array::from_fn(|_| PlantState { | ||||
|         ..Default::default() | ||||
|     }; PLANT_COUNT]; | ||||
|     }); | ||||
|     let plant_to_pump = determine_next_plant( | ||||
|         &mut plantstate, | ||||
|         europe_time, | ||||
| @@ -426,12 +424,7 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|  | ||||
|     let stay_alive = STAY_ALIVE.load(std::sync::atomic::Ordering::Relaxed); | ||||
|     println!("Check stay alive, current state is {}", stay_alive); | ||||
|     if stay_alive { | ||||
|         drop(board); | ||||
|         let reboot_now = Arc::new(AtomicBool::new(false)); | ||||
|         let _webserver = httpd(reboot_now.clone()); | ||||
|         wait_infinity(WaitType::StayAlive, reboot_now.clone()); | ||||
|     } | ||||
|  | ||||
|     let mut did_pump = false; | ||||
|     match plant_to_pump { | ||||
|         Some(plant) => { | ||||
| @@ -461,34 +454,38 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|                 "Trying to pump for {}s with pump {} now", | ||||
|                 plant_config.pump_time_s, plant | ||||
|             ); | ||||
|             did_pump = true; | ||||
|             board.any_pump(true)?; | ||||
|             board.store_last_pump_time(plant, cur); | ||||
|             board.pump(plant, true)?; | ||||
|             board.last_pump_time(plant); | ||||
|             state.active = true; | ||||
|             for _ in 0..plant_config.pump_time_s { | ||||
|                 unsafe { vTaskDelay(CONFIG_FREERTOS_HZ) }; | ||||
|                 let p_live_topic = format!("/plant{}/p live", plant + 1); | ||||
|                 if plant_config.sensor_p { | ||||
|                     let moist = map_range_moisture( | ||||
|                         board.measure_moisture_hz(plant, plant_hal::Sensor::PUMP)? as f32, | ||||
|                     ); | ||||
|                     if online_mode == OnlineMode::Online { | ||||
|                         let _ = board.mqtt_publish( | ||||
|                             &config, | ||||
|                             &p_live_topic, | ||||
|                             option_to_string(moist.ok()).as_bytes(), | ||||
|             if !stay_alive { | ||||
|                 did_pump = true; | ||||
|                 board.any_pump(true)?; | ||||
|                 board.store_last_pump_time(plant, cur); | ||||
|                 board.pump(plant, true)?; | ||||
|                 board.last_pump_time(plant); | ||||
|                 state.active = true; | ||||
|                 for _ in 0..plant_config.pump_time_s { | ||||
|                     unsafe { vTaskDelay(CONFIG_FREERTOS_HZ) }; | ||||
|                     let p_live_topic = format!("/plant{} p live", plant + 1); | ||||
|                     if plant_config.sensor_p { | ||||
|                         let moist = map_range_moisture( | ||||
|                             board.measure_moisture_hz(plant, plant_hal::Sensor::PUMP)? as f32, | ||||
|                         ); | ||||
|                     } | ||||
|                 } else { | ||||
|                     if online_mode == OnlineMode::Online { | ||||
|                         let _ = board.mqtt_publish(&config, &p_live_topic, "disabled".as_bytes()); | ||||
|                         if online_mode == OnlineMode::Online { | ||||
|                             let _ = board.mqtt_publish( | ||||
|                                 &config, | ||||
|                                 &p_live_topic, | ||||
|                                 option_to_string(&moist.ok()).as_bytes(), | ||||
|                             ); | ||||
|                         } | ||||
|                     } else { | ||||
|                         if online_mode == OnlineMode::Online { | ||||
|                             let _ = | ||||
|                                 board.mqtt_publish(&config, &p_live_topic, "disabled".as_bytes()); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 board.pump(plant, false)?; | ||||
|             } | ||||
|  | ||||
|             board.pump(plant, false)?; | ||||
|             if plant_config.sensor_p { | ||||
|                 match map_range_moisture( | ||||
|                     board.measure_moisture_hz(plant, plant_hal::Sensor::PUMP)? as f32, | ||||
| @@ -522,7 +519,7 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|     let is_day = board.is_day(); | ||||
|     light_state.is_day = is_day; | ||||
|     light_state.out_of_work_hour = !in_time_range( | ||||
|         europe_time, | ||||
|         &europe_time, | ||||
|         config.night_lamp_hour_start, | ||||
|         config.night_lamp_hour_end, | ||||
|     ); | ||||
| @@ -604,16 +601,14 @@ fn safe_main() -> anyhow::Result<()> { | ||||
|     //is deep sleep | ||||
|     mark_app_valid(); | ||||
|  | ||||
|     unsafe { esp_deep_sleep(1000 * 1000 * 60 * deep_sleep_duration_minutes as u64) }; | ||||
| } | ||||
|     if stay_alive { | ||||
|         drop(board); | ||||
|         let reboot_now = Arc::new(AtomicBool::new(false)); | ||||
|         let _webserver = httpd(reboot_now.clone()); | ||||
|         wait_infinity(WaitType::StayAlive, reboot_now.clone()); | ||||
|     } | ||||
|  | ||||
| fn to_string<T: Display>(value: Result<T>) -> String { | ||||
|     return match value { | ||||
|         Ok(v) => v.to_string(), | ||||
|         Err(err) => { | ||||
|             format!("{:?}", err) | ||||
|         } | ||||
|     }; | ||||
|     unsafe { esp_deep_sleep(1000 * 1000 * 60 * deep_sleep_duration_minutes as u64) }; | ||||
| } | ||||
|  | ||||
| fn publish_battery_state( | ||||
| @@ -621,13 +616,13 @@ fn publish_battery_state( | ||||
|     config: &Config, | ||||
| ) { | ||||
|     let bat = BatteryState { | ||||
|         voltage_milli_volt: &to_string(board.voltage_milli_volt()), | ||||
|         current_milli_ampere: &to_string(board.average_current_milli_ampere()), | ||||
|         cycle_count: &to_string(board.cycle_count()), | ||||
|         design_milli_ampere: &to_string(board.design_milli_ampere_hour()), | ||||
|         remaining_milli_ampere: &to_string(board.remaining_milli_ampere_hour()), | ||||
|         state_of_charge: &to_string(board.state_charge_percent()), | ||||
|         state_of_health: &to_string(board.state_health_percent()), | ||||
|         voltage_milli_volt: &to_string(&board.voltage_milli_volt()), | ||||
|         current_milli_ampere: &to_string(&board.average_current_milli_ampere()), | ||||
|         cycle_count: &to_string(&board.cycle_count()), | ||||
|         design_milli_ampere: &to_string(&board.design_milli_ampere_hour()), | ||||
|         remaining_milli_ampere: &to_string(&board.remaining_milli_ampere_hour()), | ||||
|         state_of_charge: &to_string(&board.state_charge_percent()), | ||||
|         state_of_health: &to_string(&board.state_health_percent()), | ||||
|     }; | ||||
|     match serde_json::to_string(&bat) { | ||||
|         Ok(state) => { | ||||
| @@ -701,54 +696,6 @@ fn determine_tank_state( | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn map_range(from_range: (f32, f32), s: f32) -> anyhow::Result<f32> { | ||||
|     if s < from_range.0 { | ||||
|         anyhow::bail!( | ||||
|             "Value out of range, min {} but current is {}", | ||||
|             from_range.0, | ||||
|             s | ||||
|         ); | ||||
|     } | ||||
|     if s > from_range.1 { | ||||
|         anyhow::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, SensorError> { | ||||
|     if s < FROM.0 { | ||||
|         return Err(SensorError::OpenCircuit { hz: s, min: FROM.0 }); | ||||
|     } | ||||
|     if s > FROM.1 { | ||||
|         return Err(SensorError::ShortCircuit { hz: s, max: FROM.1 }); | ||||
|     } | ||||
|     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 option_to_string(value: Option<u8>) -> String { | ||||
|     match value { | ||||
|         Some(v) => v.to_string(), | ||||
|         None => "Error".to_owned(), | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn determine_state_target_moisture_for_plant( | ||||
|     board: &mut std::sync::MutexGuard<'_, PlantCtrlBoard<'_>>, | ||||
|     plant: usize, | ||||
| @@ -761,7 +708,20 @@ fn determine_state_target_moisture_for_plant( | ||||
|     if plant_config.mode == Mode::OFF { | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     match board.measure_moisture_hz(plant, plant_hal::Sensor::A) { | ||||
|         Ok(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) => { | ||||
| @@ -813,7 +773,7 @@ fn determine_state_target_moisture_for_plant( | ||||
|     } | ||||
|  | ||||
|     if !in_time_range( | ||||
|         cur, | ||||
|         &cur, | ||||
|         plant_config.pump_hour_start, | ||||
|         plant_config.pump_hour_end, | ||||
|     ) { | ||||
| @@ -883,7 +843,7 @@ fn determine_state_timer_and_deadzone_for_plant( | ||||
|                 state.cooldown = true; | ||||
|             } | ||||
|             if !in_time_range( | ||||
|                 cur, | ||||
|                 &cur, | ||||
|                 plant_config.pump_hour_start, | ||||
|                 plant_config.pump_hour_end, | ||||
|             ) { | ||||
| @@ -976,13 +936,17 @@ fn update_plant_state( | ||||
|         let mode = format!("{:?}", plant_config.mode); | ||||
|  | ||||
|         let plant_dto = PlantStateMQTT { | ||||
|             p_start: &sensor_to_string(state.p, state.sensor_error_p, plant_config.sensor_p), | ||||
|             p_end: &sensor_to_string(state.after_p, state.sensor_error_p, plant_config.sensor_p), | ||||
|             a: &sensor_to_string(state.a, state.sensor_error_a, true), | ||||
|             b: &sensor_to_string(state.b, state.sensor_error_b, plant_config.sensor_b), | ||||
|             p_start: &sensor_to_string(&state.p, &state.sensor_error_p, plant_config.sensor_p), | ||||
|             p_end: &sensor_to_string(&state.after_p, &state.sensor_error_p, plant_config.sensor_p), | ||||
|             a: &sensor_to_string( | ||||
|                 &state.a, | ||||
|                 &state.sensor_error_a, | ||||
|                 plant_config.mode != Mode::OFF, | ||||
|             ), | ||||
|             b: &sensor_to_string(&state.b, &state.sensor_error_b, plant_config.sensor_b), | ||||
|             active: state.active, | ||||
|             mode: &mode, | ||||
|             last_pump: &&time_to_string_utc(board.last_pump_time(plant)), | ||||
|             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, | ||||
| @@ -996,6 +960,8 @@ fn update_plant_state( | ||||
|             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); | ||||
| @@ -1004,39 +970,6 @@ fn update_plant_state( | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn time_to_string_utc(value_option: Option<DateTime<Utc>>) -> String { | ||||
|     let converted = value_option.and_then(|utc| Some(utc.with_timezone(&Berlin))); | ||||
|     return time_to_string(converted); | ||||
| } | ||||
|  | ||||
| fn time_to_string(value_option: Option<DateTime<Tz>>) -> String { | ||||
|     match value_option { | ||||
|         Some(value) => { | ||||
|             let europe_time = value.with_timezone(&Berlin); | ||||
|             if europe_time.year() > 2023 { | ||||
|                 return europe_time.to_rfc3339(); | ||||
|             } else { | ||||
|                 return "smtp error".to_owned(); | ||||
|             } | ||||
|         } | ||||
|         None => return "N/A".to_owned(), | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn sensor_to_string(value: Option<u8>, error: Option<SensorError>, enabled: bool) -> String { | ||||
|     if enabled { | ||||
|         match error { | ||||
|             Some(error) => return format!("{:?}", error), | ||||
|             None => match value { | ||||
|                 Some(v) => return v.to_string(), | ||||
|                 None => return "Error".to_owned(), | ||||
|             }, | ||||
|         } | ||||
|     } else { | ||||
|         return "disabled".to_owned(); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn wait_infinity(wait_type: WaitType, reboot_now: Arc<AtomicBool>) -> ! { | ||||
|     let delay = match wait_type { | ||||
|         WaitType::InitialConfig => 250_u32, | ||||
| @@ -1091,7 +1024,94 @@ fn main() { | ||||
|         } | ||||
|     } | ||||
| } | ||||
| //error codes | ||||
| //error_reading_config_after_upgrade | ||||
| //error_no_config_after_upgrade | ||||
| //error_tank_sensor_fault | ||||
|  | ||||
| fn time_to_string_utc(value_option: Option<DateTime<Utc>>) -> String { | ||||
|     let converted = value_option.and_then(|utc| Some(utc.with_timezone(&Berlin))); | ||||
|     return time_to_string(converted); | ||||
| } | ||||
|  | ||||
| fn time_to_string(value_option: Option<DateTime<Tz>>) -> String { | ||||
|     match value_option { | ||||
|         Some(value) => { | ||||
|             let europe_time = value.with_timezone(&Berlin); | ||||
|             if europe_time.year() > 2023 { | ||||
|                 return europe_time.to_rfc3339(); | ||||
|             } else { | ||||
|                 //initial value of 0 in rtc memory | ||||
|                 return "N/A".to_owned(); | ||||
|             } | ||||
|         } | ||||
|         None => return "N/A".to_owned(), | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn sensor_to_string(value: &Option<u8>, error: &Option<SensorError>, enabled: bool) -> String { | ||||
|     if enabled { | ||||
|         match error { | ||||
|             Some(error) => return format!("{:?}", error), | ||||
|             None => match value { | ||||
|                 Some(v) => return v.to_string(), | ||||
|                 None => return "Error".to_owned(), | ||||
|             }, | ||||
|         } | ||||
|     } else { | ||||
|         return "disabled".to_owned(); | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn to_string<T: Display>(value: &Result<T>) -> String { | ||||
|     return match value { | ||||
|         Ok(v) => v.to_string(), | ||||
|         Err(err) => { | ||||
|             format!("{:?}", err) | ||||
|         } | ||||
|     }; | ||||
| } | ||||
|  | ||||
| fn map_range(from_range: (f32, f32), s: f32) -> anyhow::Result<f32> { | ||||
|     if s < from_range.0 { | ||||
|         anyhow::bail!( | ||||
|             "Value out of range, min {} but current is {}", | ||||
|             from_range.0, | ||||
|             s | ||||
|         ); | ||||
|     } | ||||
|     if s > from_range.1 { | ||||
|         anyhow::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, SensorError> { | ||||
|     if s < FROM.0 { | ||||
|         return Err(SensorError::OpenCircuit { hz: s, min: FROM.0 }); | ||||
|     } | ||||
|     if s > FROM.1 { | ||||
|         return Err(SensorError::ShortCircuit { hz: s, max: FROM.1 }); | ||||
|     } | ||||
|     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 option_to_string(value: &Option<u8>) -> String { | ||||
|     match value { | ||||
|         Some(v) => v.to_string(), | ||||
|         None => "Error".to_owned(), | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -17,7 +17,7 @@ use esp_idf_svc::mqtt::client::{EspMqttClient, LwtConfiguration, MqttClientConfi | ||||
| use esp_idf_svc::nvs::EspDefaultNvsPartition; | ||||
| use esp_idf_svc::wifi::config::{ScanConfig, ScanType}; | ||||
| use esp_idf_svc::wifi::EspWifi; | ||||
| use measurements::{Frequency, Temperature}; | ||||
| use measurements::Temperature; | ||||
| use plant_ctrl2::sipo::ShiftRegister40; | ||||
|  | ||||
| use anyhow::anyhow; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user