clcass
This commit is contained in:
1 parent
9dba818a12
commit
6a0e3f0070
1 file changed
+178
-97
+178
-97
@@ -1,9 +1,17 @@
|
|||||||
//! Kettle controller: press the switch to boil to a setpoint, then brew-timer.
|
//! Kettle controller (finite state machine): press the switch to boil to a
|
||||||
|
//! setpoint, then run a brew timer.
|
||||||
//!
|
//!
|
||||||
//! The button behaves like a kettle's on-switch: pressing it engages the heater
|
//! States: Idle → Heating → Brewing → Idle, plus Fault (sensor disconnected).
|
||||||
//! (the state latches in firmware, like the switch staying down), and the
|
//! * a press while below setpoint: Idle/Brewing → Heating (heater on)
|
||||||
//! heater runs until the water reaches the setpoint, at which point it switches
|
//! * reaching the setpoint: Heating → Brewing (heater off, timer)
|
||||||
//! off ("pops up") and a brew timer starts.
|
//! * timer expiry: Brewing → Idle
|
||||||
|
//! * raw ADC == 0 (open thermistor): any → Fault (heater off)
|
||||||
|
//!
|
||||||
|
//! The `Kettle` struct owns the FSM state and the actuators (heater + LEDs);
|
||||||
|
//! `main` owns the sensor/button/timer harness and drives `Kettle`'s methods.
|
||||||
|
//!
|
||||||
|
//! The button behaves like a kettle's on-switch (latches the heater on until
|
||||||
|
//! the setpoint is reached). It is polled every POLL_MS so a quick tap registers.
|
||||||
//!
|
//!
|
||||||
//! Sensor — NTC divider on the 3V rail (NOT 5V; ADC ref is VDDA ~= 3.0 V):
|
//! Sensor — NTC divider on the 3V rail (NOT 5V; ADC ref is VDDA ~= 3.0 V):
|
||||||
//!
|
//!
|
||||||
@@ -12,9 +20,6 @@
|
|||||||
//! └── 100nF ──── GND (optional noise filter)
|
//! └── 100nF ──── GND (optional noise filter)
|
||||||
//! R_ntc = R_FIXED * (ADC_MAX - raw) / raw (ratiometric), then Beta -> degC
|
//! R_ntc = R_FIXED * (ADC_MAX - raw) / raw (ratiometric), then Beta -> degC
|
||||||
//!
|
//!
|
||||||
//! On-switch — the board's B1 USER button (PA0, active-high). It is polled
|
|
||||||
//! every POLL_MS (not just once per control tick), so a quick tap registers.
|
|
||||||
//!
|
|
||||||
//! Outputs (LEDs via their own ~220-330R resistor, anode to pin, cathode GND):
|
//! Outputs (LEDs via their own ~220-330R resistor, anode to pin, cathode GND):
|
||||||
//!
|
//!
|
||||||
//! PC5 (P1 pin 19) HEATER -> relay/SSR (high = heat on)
|
//! PC5 (P1 pin 19) HEATER -> relay/SSR (high = heat on)
|
||||||
@@ -37,6 +42,10 @@ use f411::{
|
|||||||
config::{AdcConfig, SampleTime},
|
config::{AdcConfig, SampleTime},
|
||||||
Adc,
|
Adc,
|
||||||
},
|
},
|
||||||
|
gpio::{
|
||||||
|
gpioc::{PC2, PC4, PC5},
|
||||||
|
Output, PushPull,
|
||||||
|
},
|
||||||
pac,
|
pac,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
rcc::Config,
|
rcc::Config,
|
||||||
@@ -58,13 +67,145 @@ const ADC_MAX: f32 = 4095.0;
|
|||||||
const SETPOINT_C: f32 = 25.0;
|
const SETPOINT_C: f32 = 25.0;
|
||||||
|
|
||||||
// ---- Timing ----
|
// ---- Timing ----
|
||||||
// Control cadence: temperature is read and the timer advances once per tick.
|
const TICK_MS: u32 = 1000; // control cadence (temperature read, timer advance)
|
||||||
const TICK_MS: u32 = 1000;
|
const POLL_MS: u32 = 20; // button poll interval within a tick
|
||||||
// Button poll interval within a tick — short enough to catch a normal tap.
|
const BREW_TICKS: u32 = 10; // brew duration in ticks (= seconds); set to taste
|
||||||
const POLL_MS: u32 = 20;
|
|
||||||
// brew duration, in ticks (= seconds at TICK_MS = 1000). Short for the bench;
|
/// Controller state.
|
||||||
// set to your brew/steep time (e.g. 180 for 3 minutes).
|
#[derive(Clone, Copy, PartialEq, defmt::Format)]
|
||||||
const BREW_TICKS: u32 = 10;
|
enum State {
|
||||||
|
/// Off, waiting for a press.
|
||||||
|
Idle,
|
||||||
|
/// Heater engaged, climbing toward the setpoint.
|
||||||
|
Heating,
|
||||||
|
/// Setpoint reached; heater off, brew timer counting down (ticks left).
|
||||||
|
Brewing { remaining: u32 },
|
||||||
|
/// Thermistor reads open/short; heater forced off until it recovers.
|
||||||
|
Fault,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kettle FSM plus the actuators it drives.
|
||||||
|
struct Kttl {
|
||||||
|
state: State,
|
||||||
|
heater: PC5<Output<PushPull>>,
|
||||||
|
led_heating: PC2<Output<PushPull>>, // red: heater on
|
||||||
|
led_at_temp: PC4<Output<PushPull>>, // blue: at/above setpoint
|
||||||
|
compass: LedCompass, // on-board LEDs; north = brew timer
|
||||||
|
prev_pressed: bool, // for press edge detection
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kttl {
|
||||||
|
fn new(
|
||||||
|
heater: PC5<Output<PushPull>>,
|
||||||
|
led_heating: PC2<Output<PushPull>>,
|
||||||
|
led_at_temp: PC4<Output<PushPull>>,
|
||||||
|
mut compass: LedCompass,
|
||||||
|
pressed: bool,
|
||||||
|
) -> Self {
|
||||||
|
// Only the north LED is used; park the rest off.
|
||||||
|
compass.e.off();
|
||||||
|
compass.s.off();
|
||||||
|
compass.w.off();
|
||||||
|
let mut kettle = Self {
|
||||||
|
state: State::Idle,
|
||||||
|
heater,
|
||||||
|
led_heating,
|
||||||
|
led_at_temp,
|
||||||
|
compass,
|
||||||
|
prev_pressed: pressed,
|
||||||
|
};
|
||||||
|
kettle.apply_outputs(0.0);
|
||||||
|
kettle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> State {
|
||||||
|
self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-tick, temperature/timer-driven transitions.
|
||||||
|
fn tick(&mut self, temp_c: f32) {
|
||||||
|
self.state = match self.state {
|
||||||
|
State::Fault => {
|
||||||
|
defmt::info!("thermistor reconnected");
|
||||||
|
State::Idle
|
||||||
|
}
|
||||||
|
State::Heating if temp_c >= SETPOINT_C => {
|
||||||
|
defmt::info!("setpoint reached — switched OFF, brew {} s", BREW_TICKS);
|
||||||
|
State::Brewing { remaining: BREW_TICKS }
|
||||||
|
}
|
||||||
|
State::Brewing { remaining } => {
|
||||||
|
let r = remaining - 1;
|
||||||
|
if r == 0 {
|
||||||
|
defmt::info!("brew COMPLETE");
|
||||||
|
State::Idle
|
||||||
|
} else {
|
||||||
|
State::Brewing { remaining: r }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other, // Idle, or Heating still below setpoint
|
||||||
|
};
|
||||||
|
self.apply_outputs(temp_c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open/short thermistor: force the Fault state and the heater off.
|
||||||
|
fn fault(&mut self) {
|
||||||
|
if self.state != State::Fault {
|
||||||
|
defmt::warn!("thermistor disconnected — heater forced OFF");
|
||||||
|
}
|
||||||
|
self.state = State::Fault;
|
||||||
|
self.apply_outputs(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the on-switch level. A press edge engages the heater, but only
|
||||||
|
/// from Idle/Brewing and only while below the setpoint.
|
||||||
|
fn press(&mut self, pressed: bool, temp_c: f32) {
|
||||||
|
if pressed && !self.prev_pressed {
|
||||||
|
match self.state {
|
||||||
|
State::Idle | State::Brewing { .. } if temp_c < SETPOINT_C => {
|
||||||
|
self.state = State::Heating;
|
||||||
|
self.apply_outputs(temp_c);
|
||||||
|
defmt::info!("switched ON");
|
||||||
|
}
|
||||||
|
State::Idle | State::Brewing { .. } => {
|
||||||
|
defmt::info!("press ignored — already at/above setpoint");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.prev_pressed = pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive all outputs from the current state.
|
||||||
|
fn apply_outputs(&mut self, temp_c: f32) {
|
||||||
|
match self.state {
|
||||||
|
State::Heating => {
|
||||||
|
self.heater.set_high();
|
||||||
|
self.led_heating.set_high();
|
||||||
|
self.led_at_temp.set_state((temp_c >= SETPOINT_C).into());
|
||||||
|
self.compass.n.off();
|
||||||
|
}
|
||||||
|
State::Brewing { .. } => {
|
||||||
|
self.heater.set_low();
|
||||||
|
self.led_heating.set_low();
|
||||||
|
self.led_at_temp.set_state((temp_c >= SETPOINT_C).into());
|
||||||
|
self.compass.n.on();
|
||||||
|
}
|
||||||
|
State::Idle => {
|
||||||
|
self.heater.set_low();
|
||||||
|
self.led_heating.set_low();
|
||||||
|
self.led_at_temp.set_state((temp_c >= SETPOINT_C).into());
|
||||||
|
self.compass.n.off();
|
||||||
|
}
|
||||||
|
State::Fault => {
|
||||||
|
// Temperature is invalid in Fault — drive everything off.
|
||||||
|
self.heater.set_low();
|
||||||
|
self.led_heating.set_low();
|
||||||
|
self.led_at_temp.set_low();
|
||||||
|
self.compass.n.off();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[entry]
|
#[entry]
|
||||||
fn main() -> ! {
|
fn main() -> ! {
|
||||||
@@ -80,44 +221,32 @@ fn main() -> ! {
|
|||||||
|
|
||||||
let gpioc = p.GPIOC.split(&mut rcc);
|
let gpioc = p.GPIOC.split(&mut rcc);
|
||||||
let therm = gpioc.pc1.into_analog();
|
let therm = gpioc.pc1.into_analog();
|
||||||
let mut heater = gpioc.pc5.into_push_pull_output(); // actuator (relay/SSR later)
|
let heater = gpioc.pc5.into_push_pull_output();
|
||||||
let mut led_heating = gpioc.pc2.into_push_pull_output(); // red: heater on
|
let led_heating = gpioc.pc2.into_push_pull_output();
|
||||||
let mut led_at_temp = gpioc.pc4.into_push_pull_output(); // blue: at/above setpoint
|
let led_at_temp = gpioc.pc4.into_push_pull_output();
|
||||||
|
let compass = LedCompass::new(p.GPIOD.split(&mut rcc));
|
||||||
// On-board user LEDs (PD12-15) used as the brew-timer indicator; only the
|
|
||||||
// north LED (LD3, orange) is driven.
|
|
||||||
let mut compass = LedCompass::new(p.GPIOD.split(&mut rcc));
|
|
||||||
|
|
||||||
heater.set_low();
|
|
||||||
led_heating.set_low();
|
|
||||||
led_at_temp.set_low();
|
|
||||||
compass.n.off();
|
|
||||||
compass.e.off();
|
|
||||||
compass.s.off();
|
|
||||||
compass.w.off();
|
|
||||||
|
|
||||||
let mut adc = Adc::new(p.ADC1, true, AdcConfig::default(), &mut rcc);
|
let mut adc = Adc::new(p.ADC1, true, AdcConfig::default(), &mut rcc);
|
||||||
let mut delay = cp.SYST.delay(&rcc.clocks);
|
let mut delay = cp.SYST.delay(&rcc.clocks);
|
||||||
|
|
||||||
defmt::info!("kettle: setpoint {} C, brew {} s", SETPOINT_C, BREW_TICKS);
|
defmt::info!("kettle: setpoint {} C, brew {} s", SETPOINT_C, BREW_TICKS);
|
||||||
|
|
||||||
// ---- state (carried across ticks) ----
|
let mut kettle = Kttl::new(heater, led_heating, led_at_temp, compass, button.is_high());
|
||||||
let mut heating = false; // kettle switched on, heating toward setpoint
|
|
||||||
let mut countdown: Option<u32> = None; // brew-timer ticks remaining
|
|
||||||
let mut prev_pressed = button.is_high(); // for press edge detection
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let raw = adc.convert(&therm, SampleTime::Cycles_480);
|
let raw = adc.convert(&therm, SampleTime::Cycles_480);
|
||||||
|
|
||||||
// Fail-safe: a disconnected thermistor reads raw 0; force the heater off
|
// ---- Fault path: open/short thermistor reads raw 0 ----
|
||||||
// rather than risk boiling dry, and freeze the rest of the state.
|
|
||||||
if raw == 0 {
|
if raw == 0 {
|
||||||
heating = false;
|
kettle.fault();
|
||||||
heater.set_low();
|
// Wait a tick; keep tracking the button so we don't fire a phantom
|
||||||
led_heating.set_low();
|
// press edge on recovery (temp is irrelevant while faulted).
|
||||||
defmt::warn!("raw=0 — thermistor disconnected? heater forced OFF");
|
let mut waited = 0;
|
||||||
prev_pressed = button.is_high();
|
while waited < TICK_MS {
|
||||||
delay.delay_ms(TICK_MS);
|
delay.delay_ms(POLL_MS);
|
||||||
|
waited += POLL_MS;
|
||||||
|
kettle.press(button.is_high(), 0.0);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,73 +255,25 @@ fn main() -> ! {
|
|||||||
let inv_t = 1.0 / T0 + (1.0 / BETA) * libm::logf(r_ntc / R0);
|
let inv_t = 1.0 / T0 + (1.0 / BETA) * libm::logf(r_ntc / R0);
|
||||||
let temp_c = 1.0 / inv_t - 273.15;
|
let temp_c = 1.0 / inv_t - 273.15;
|
||||||
|
|
||||||
// Reaching the setpoint switches the kettle off ("pops up") and starts
|
kettle.tick(temp_c);
|
||||||
// the brew timer.
|
|
||||||
if heating && temp_c >= SETPOINT_C {
|
|
||||||
heating = false;
|
|
||||||
countdown = Some(BREW_TICKS);
|
|
||||||
defmt::info!("setpoint reached — switched OFF, brew {} s", BREW_TICKS);
|
|
||||||
}
|
|
||||||
|
|
||||||
let heat_on = heating;
|
// Status line. defmt has no float-precision hint, so render the
|
||||||
heater.set_state(heat_on.into());
|
// temperature to one decimal by hand via deci-degrees.
|
||||||
led_heating.set_state(heat_on.into());
|
|
||||||
led_at_temp.set_state((temp_c >= SETPOINT_C).into());
|
|
||||||
|
|
||||||
// Advance the brew timer.
|
|
||||||
if let Some(n) = countdown {
|
|
||||||
let n = n - 1;
|
|
||||||
if n == 0 {
|
|
||||||
countdown = None;
|
|
||||||
defmt::info!("brew COMPLETE");
|
|
||||||
} else {
|
|
||||||
countdown = Some(n);
|
|
||||||
defmt::info!("brew: {} s left", n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// brew-timer indicator: north LED (orange) lit while counting.
|
|
||||||
if countdown.is_some() {
|
|
||||||
compass.n.on();
|
|
||||||
} else {
|
|
||||||
compass.n.off();
|
|
||||||
}
|
|
||||||
|
|
||||||
// defmt has no float-precision hint, so render the temperature to one
|
|
||||||
// decimal by hand via deci-degrees; heater state is shown as a word.
|
|
||||||
let t_dc = libm::roundf(temp_c * 10.0) as i32; // tenths of a degree C
|
let t_dc = libm::roundf(temp_c * 10.0) as i32; // tenths of a degree C
|
||||||
defmt::info!(
|
defmt::info!(
|
||||||
"T={=i32}.{=u32} C set={} C heater {}",
|
"T={=i32}.{=u32} C set={} C {}",
|
||||||
t_dc / 10,
|
t_dc / 10,
|
||||||
(t_dc % 10).unsigned_abs(),
|
(t_dc % 10).unsigned_abs(),
|
||||||
SETPOINT_C,
|
SETPOINT_C,
|
||||||
if heat_on { "ON" } else { "off" },
|
kettle.state()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait one control tick, polling the on-switch frequently so a quick tap
|
// ---- wait one tick, polling the on-switch for a press event ----
|
||||||
// isn't missed. A press engages the heater immediately and cancels any
|
|
||||||
// running brew timer — like pushing the kettle switch back down.
|
|
||||||
let mut waited = 0;
|
let mut waited = 0;
|
||||||
while waited < TICK_MS {
|
while waited < TICK_MS {
|
||||||
delay.delay_ms(POLL_MS);
|
delay.delay_ms(POLL_MS);
|
||||||
waited += POLL_MS;
|
waited += POLL_MS;
|
||||||
|
kettle.press(button.is_high(), temp_c);
|
||||||
let pressed = button.is_high();
|
|
||||||
if pressed && !prev_pressed {
|
|
||||||
// Engage only if there is actually heating to do. Uses the most
|
|
||||||
// recent temperature reading (from the top of this tick).
|
|
||||||
if temp_c < SETPOINT_C {
|
|
||||||
heating = true;
|
|
||||||
countdown = None;
|
|
||||||
heater.set_high();
|
|
||||||
led_heating.set_high();
|
|
||||||
compass.n.off();
|
|
||||||
defmt::info!("switched ON");
|
|
||||||
} else {
|
|
||||||
defmt::info!("press ignored — already at/above setpoint");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev_pressed = pressed;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user