This commit is contained in:
Nathan Rashleigh committed 2026-06-14 21:01:13 +10:00
1 parent 9dba818a12
commit 6a0e3f0070
1 file changed
+178 -97
+178 -97
View File
@@ -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;
} }
} }
} }