From 6a0e3f0070a307bc633a88a9326760dd0cf6f57f Mon Sep 17 00:00:00 2001 From: Nathan Rashleigh Date: Sun, 14 Jun 2026 21:01:13 +1000 Subject: [PATCH] clcass --- src/main.rs | 275 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 178 insertions(+), 97 deletions(-) diff --git a/src/main.rs b/src/main.rs index acd59d6..1866937 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 -//! (the state latches in firmware, like the switch staying down), and the -//! heater runs until the water reaches the setpoint, at which point it switches -//! off ("pops up") and a brew timer starts. +//! States: Idle → Heating → Brewing → Idle, plus Fault (sensor disconnected). +//! * a press while below setpoint: Idle/Brewing → Heating (heater on) +//! * reaching the setpoint: Heating → Brewing (heater off, timer) +//! * 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): //! @@ -12,9 +20,6 @@ //! └── 100nF ──── GND (optional noise filter) //! 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): //! //! PC5 (P1 pin 19) HEATER -> relay/SSR (high = heat on) @@ -37,6 +42,10 @@ use f411::{ config::{AdcConfig, SampleTime}, Adc, }, + gpio::{ + gpioc::{PC2, PC4, PC5}, + Output, PushPull, + }, pac, prelude::*, rcc::Config, @@ -58,13 +67,145 @@ const ADC_MAX: f32 = 4095.0; const SETPOINT_C: f32 = 25.0; // ---- Timing ---- -// Control cadence: temperature is read and the timer advances once per tick. -const TICK_MS: u32 = 1000; -// Button poll interval within a tick — short enough to catch a normal tap. -const POLL_MS: u32 = 20; -// brew duration, in ticks (= seconds at TICK_MS = 1000). Short for the bench; -// set to your brew/steep time (e.g. 180 for 3 minutes). -const BREW_TICKS: u32 = 10; +const TICK_MS: u32 = 1000; // control cadence (temperature read, timer advance) +const POLL_MS: u32 = 20; // button poll interval within a tick +const BREW_TICKS: u32 = 10; // brew duration in ticks (= seconds); set to taste + +/// Controller state. +#[derive(Clone, Copy, PartialEq, defmt::Format)] +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>, + led_heating: PC2>, // red: heater on + led_at_temp: PC4>, // 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>, + led_heating: PC2>, + led_at_temp: PC4>, + 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] fn main() -> ! { @@ -80,44 +221,32 @@ fn main() -> ! { let gpioc = p.GPIOC.split(&mut rcc); let therm = gpioc.pc1.into_analog(); - let mut heater = gpioc.pc5.into_push_pull_output(); // actuator (relay/SSR later) - let mut led_heating = gpioc.pc2.into_push_pull_output(); // red: heater on - let mut led_at_temp = gpioc.pc4.into_push_pull_output(); // blue: at/above setpoint - - // 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 heater = gpioc.pc5.into_push_pull_output(); + let led_heating = gpioc.pc2.into_push_pull_output(); + let led_at_temp = gpioc.pc4.into_push_pull_output(); + let compass = LedCompass::new(p.GPIOD.split(&mut rcc)); let mut adc = Adc::new(p.ADC1, true, AdcConfig::default(), &mut rcc); let mut delay = cp.SYST.delay(&rcc.clocks); defmt::info!("kettle: setpoint {} C, brew {} s", SETPOINT_C, BREW_TICKS); - // ---- state (carried across ticks) ---- - let mut heating = false; // kettle switched on, heating toward setpoint - let mut countdown: Option = None; // brew-timer ticks remaining - let mut prev_pressed = button.is_high(); // for press edge detection + let mut kettle = Kttl::new(heater, led_heating, led_at_temp, compass, button.is_high()); loop { let raw = adc.convert(&therm, SampleTime::Cycles_480); - // Fail-safe: a disconnected thermistor reads raw 0; force the heater off - // rather than risk boiling dry, and freeze the rest of the state. + // ---- Fault path: open/short thermistor reads raw 0 ---- if raw == 0 { - heating = false; - heater.set_low(); - led_heating.set_low(); - defmt::warn!("raw=0 — thermistor disconnected? heater forced OFF"); - prev_pressed = button.is_high(); - delay.delay_ms(TICK_MS); + kettle.fault(); + // Wait a tick; keep tracking the button so we don't fire a phantom + // press edge on recovery (temp is irrelevant while faulted). + let mut waited = 0; + while waited < TICK_MS { + delay.delay_ms(POLL_MS); + waited += POLL_MS; + kettle.press(button.is_high(), 0.0); + } continue; } @@ -126,73 +255,25 @@ fn main() -> ! { let inv_t = 1.0 / T0 + (1.0 / BETA) * libm::logf(r_ntc / R0); let temp_c = 1.0 / inv_t - 273.15; - // Reaching the setpoint switches the kettle off ("pops up") and starts - // 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); - } + kettle.tick(temp_c); - let heat_on = heating; - heater.set_state(heat_on.into()); - 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. + // Status line. defmt has no float-precision hint, so render the + // temperature to one decimal by hand via deci-degrees. let t_dc = libm::roundf(temp_c * 10.0) as i32; // tenths of a degree C defmt::info!( - "T={=i32}.{=u32} C set={} C heater {}", + "T={=i32}.{=u32} C set={} C {}", t_dc / 10, (t_dc % 10).unsigned_abs(), SETPOINT_C, - if heat_on { "ON" } else { "off" }, + kettle.state() ); - // Wait one control tick, polling the on-switch frequently so a quick tap - // isn't missed. A press engages the heater immediately and cancels any - // running brew timer — like pushing the kettle switch back down. + // ---- wait one tick, polling the on-switch for a press event ---- let mut waited = 0; while waited < TICK_MS { delay.delay_ms(POLL_MS); waited += POLL_MS; - - 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; + kettle.press(button.is_high(), temp_c); } } }