diff --git a/etc/rotary.md b/etc/rotary.md new file mode 100644 index 0000000..1299307 --- /dev/null +++ b/etc/rotary.md @@ -0,0 +1,125 @@ +# Rotary Encoder — Setpoint Control Wiring + +Adds an EC11-class incremental rotary encoder to the kttl controller so the +temperature **setpoint** can be dialled in live, and so the encoder's +**push-switch** acts as the on-switch (identical to the board's B1 button). + +Target board: **STM32F4-Discovery (STM32F411VE)**. Firmware: `src/main.rs` +(`SetpointKnob`, TIM1 in encoder mode). + +--- + +## 1. Pin assignment + +| Encoder leg | Discovery pin | Function | Extra parts | +|-----------------|------------------|---------------------------|-----------------------------| +| **A** (CLK) | **PE9** (TIM1_CH1) | quadrature channel A | 10 kΩ → 3V, 100 nF → GND | +| **B** (DT) | **PE11** (TIM1_CH2) | quadrature channel B | 10 kΩ → 3V, 100 nF → GND | +| **C** (common) | **GND** | rotary common | — | +| **SW1** | **3V** | push-switch leg | — | +| **SW2** | **PA0** | push-switch leg (‖ B1) | — | +| mount ×2 | **GND** | shield / mechanical | optional | + +> **Use 3V, not 5V.** The STM32F411 is a 3.3 V part; the pull-ups set the logic +> high level seen by the pins, and the switch drives PA0 directly. 3V matches +> the MCU and the existing B1 button exactly — no level shifting. + +--- + +## 2. Schematic + +``` + STM32F4-Discovery Rotary encoder (EC11, 7-leg) + ┌──────────────┐ + │ 3V ●───────────┬───────────┬──────────────┐ + │ │ │ │ │ + │ │ ┌┴┐ ┌┴┐ │ + │ │ 10k │ │ 10k │ │ │ (pull-ups + │ │ └┬┘ └┬┘ │ to 3V) + │ │ │ │ │ + │ PE9 (CH1) ●─┼───────────┼────────────● A (CLK) │ + │ │ │ │ │ + │ │ 100n═╪═ 100n═╪═ │ + │ │ │ │ │ + │ PE11 (CH2) ●─┼───────────●────────────┼──● B (DT) │ + │ │ │ │ + │ │ ● C (common)──┼──┐ + │ │ │ │ + │ │ SW1 ●────────────────┘ │ + │ PA0 ●───────────────────┐ │ (push-switch) │ + │ │ │ ● SW2 ─────────────┼──┐ + │ GND ●──────┬─────────────┼─────────────────────┘ │ + │ │ │ │ mount legs ●●─────────┘ + └──────────────┘ │ │ (to GND, shield) + │ │ + ┌──────┴──────┐ │ B1 (on-board) also sits + │ both 100n │ │ 3V ↔ PA0 — encoder SW is + │ caps → GND │ └─ in PARALLEL with it. + └─────────────┘ +``` + +--- + +## 3. Breadboard view + +``` + 3V rail ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━● ← jumper from Discovery 3V + GND rail ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━● ← jumper from Discovery GND + ┌──────────────────────────────────────────────────┐ + │ [10k] [10k] encoder straddles trench │ + │ │ │ │ │ ┌──────────────────────┐ │ + 3V ──┴─┘ └──┬──┘ └──┬──────┤ A C B │ SW1 SW2 ├─┐ │ + │ │ │ └──┼────┼────┼───┼────┼──┘ │ │ + │ [100n] [100n] │ GND │ 3V PA0 │ │ + │ │ │ │ │ │ │ + GND ─┴─────┴───────┴───────────┘ │ │ │ + │ │ │ │ + │ A → PE9 B → PE11 SW2 → PA0 │ │ + └──────────────────────────────────────────────────┘ + (jumper) (jumper) (jumper, ‖ B1) +``` + +Power both breadboard rails from the Discovery's **3V** and **GND** pins; tap +everything else off those rails. + +--- + +## 4. Why the resistors and caps + +- **10 kΩ pull-ups (A→3V, B→3V):** *required.* This HAL's QEI driver puts the + pins in alternate-function mode without enabling the internal pull-ups, so the + channels would float. With the encoder common tied to GND, the lines idle high + and each detent pulls them low. +- **100 nF caps (A→GND, B→GND):** *recommended.* Each forms an RC low-pass with + its 10 kΩ pull-up, τ = R·C = 10 kΩ · 100 nF = **1 ms** — long enough to absorb + the ~1–5 ms of mechanical contact bounce, short enough that no hand-turned knob + outruns it. Combined with the STM32 inputs' Schmitt-trigger hysteresis, this + turns each bounced click into one clean edge. The cap's closure surge also + provides wetting current to keep the low-current (~0.3 mA) contacts reliable. + +--- + +## 5. Behaviour (firmware) + +- **TIM1 encoder mode** counts quadrature edges (×4). EC11s emit one full cycle + per detent → **4 counter steps per detent** (`COUNTS_PER_DETENT`). +- Each detent moves the setpoint by **`SETPOINT_STEP_C` (1 °C)**, clamped to + **`[SETPOINT_MIN_C, SETPOINT_MAX_C]` = [25, 100] °C**. Power-on default is + **`SETPOINT_DEFAULT_C` = 25 °C**. +- The encoder is sampled every `POLL_MS` (20 ms) in the main poll loops (normal + and fault), so the setpoint tracks smoothly and the per-second status log + prints the live value. +- **Push-switch:** wired in parallel with B1 (3V ↔ PA0). Pressing the knob is + electrically identical to the board button and is handled by the existing PA0 + polling — no extra code. + +--- + +## 6. Alternatives + +- **Drop the caps:** enable TIM1's built-in digital input filter (sample-count + debounce) in firmware instead. The RC filter is simpler and also gives wetting + current + on-wire noise rejection, so it's preferred for a breadboard build. +- **KY-040 module:** has onboard pull-ups — power it from **3.3 V** (not 5 V) so + CLK/DT/SW idle at a level the MCU pins accept; the external 10 kΩ/100 nF here + can then be omitted. diff --git a/kttl.sublime-project b/kttl.sublime-project index 12b1333..6c0844e 100644 --- a/kttl.sublime-project +++ b/kttl.sublime-project @@ -5,7 +5,25 @@ "path": ".", }, { - "path": "/home/ntr/code/f411" + "path": "/home/ntr/code/f411", }, ], + "settings": + { + "LSP": + { + "gopls": + { + "enabled": false, + }, + "Deno": + { + "enabled": false, + }, + "LSP-json": + { + "enabled": true, + }, + }, + }, } diff --git a/src/main.rs b/src/main.rs index 1866937..e520b85 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,16 @@ //! PC2 (P1 pin 10) ──[R]──▶|──GND RED = heater on //! PC4 (P1 pin 20) ──[R]──▶|──GND BLUE = at/above setpoint //! on-board user LEDs (PD12-15) — brew timer: north LED (orange) = counting +//! +//! Setpoint — rotary encoder (EC11) via TIM1 in encoder mode: +//! +//! PE9 (TIM1_CH1) ── encoder A (10k pull-up to 3V, +opt 100nF to GND) +//! PE11 (TIM1_CH2) ── encoder B (10k pull-up to 3V, +opt 100nF to GND) +//! GND ── encoder C (common) +//! One detent = SETPOINT_STEP_C, clamped to [SETPOINT_MIN_C, SETPOINT_MAX_C]. +//! The encoder's push-switch wires in PARALLEL with B1 (3V ↔ PA0), so pressing +//! the knob is electrically identical to the board button — handled with no +//! extra code by the existing PA0 polling. #![no_std] #![no_main] @@ -48,6 +58,7 @@ use f411::{ }, pac, prelude::*, + qei::Qei, rcc::Config, }, led::LedCompass, @@ -62,9 +73,17 @@ const T0: f32 = 298.15; // reference temperature for R0 (kelvin, = 25 C) const ADC_MAX: f32 = 4095.0; // ---- Control parameters ---- -// Target temperature. Low (~25 C) so it can be exercised by warming the -// thermistor by hand; raise toward ~90-95 C for an actual kettle. -const SETPOINT_C: f32 = 25.0; +// Setpoint is selected live with the rotary encoder and clamped to a safe band. +// Starts low (~25 C) so it can be exercised by warming the thermistor by hand; +// turn the knob up toward ~90-95 C for an actual kettle. +const SETPOINT_DEFAULT_C: f32 = 25.0; // value at power-on +const SETPOINT_MIN_C: f32 = 25.0; // clamp floor +const SETPOINT_MAX_C: f32 = 100.0; // clamp ceiling +const SETPOINT_STEP_C: f32 = 1.0; // °C per encoder detent + +// EC11-class encoders complete one quadrature cycle per mechanical detent; +// TIM1's x4 encoder mode therefore yields 4 counter steps per detent. +const COUNTS_PER_DETENT: i16 = 4; // ---- Timing ---- const TICK_MS: u32 = 1000; // control cadence (temperature read, timer advance) @@ -92,6 +111,7 @@ struct Kttl { led_at_temp: PC4>, // blue: at/above setpoint compass: LedCompass, // on-board LEDs; north = brew timer prev_pressed: bool, // for press edge detection + setpoint_c: f32, // target temp, set live by the encoder } impl Kttl { @@ -101,6 +121,7 @@ impl Kttl { led_at_temp: PC4>, mut compass: LedCompass, pressed: bool, + setpoint_c: f32, ) -> Self { // Only the north LED is used; park the rest off. compass.e.off(); @@ -113,6 +134,7 @@ impl Kttl { led_at_temp, compass, prev_pressed: pressed, + setpoint_c, }; kettle.apply_outputs(0.0); kettle @@ -122,6 +144,16 @@ impl Kttl { self.state } + /// Current setpoint (°C), as adjusted by the rotary encoder. + fn setpoint(&self) -> f32 { + self.setpoint_c + } + + /// Update the setpoint from the encoder (caller has already clamped it). + fn set_setpoint(&mut self, setpoint_c: f32) { + self.setpoint_c = setpoint_c; + } + /// Per-tick, temperature/timer-driven transitions. fn tick(&mut self, temp_c: f32) { self.state = match self.state { @@ -129,7 +161,7 @@ impl Kttl { defmt::info!("thermistor reconnected"); State::Idle } - State::Heating if temp_c >= SETPOINT_C => { + State::Heating if temp_c >= self.setpoint_c => { defmt::info!("setpoint reached — switched OFF, brew {} s", BREW_TICKS); State::Brewing { remaining: BREW_TICKS } } @@ -161,7 +193,7 @@ impl Kttl { 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 => { + State::Idle | State::Brewing { .. } if temp_c < self.setpoint_c => { self.state = State::Heating; self.apply_outputs(temp_c); defmt::info!("switched ON"); @@ -181,19 +213,19 @@ impl Kttl { State::Heating => { self.heater.set_high(); self.led_heating.set_high(); - self.led_at_temp.set_state((temp_c >= SETPOINT_C).into()); + self.led_at_temp.set_state((temp_c >= self.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.led_at_temp.set_state((temp_c >= self.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.led_at_temp.set_state((temp_c >= self.setpoint_c).into()); self.compass.n.off(); } State::Fault => { @@ -207,6 +239,44 @@ impl Kttl { } } +/// Rotary-encoder → setpoint tracker. TIM1 in encoder mode counts quadrature +/// edges (x4: COUNTS_PER_DETENT per mechanical click); each detent nudges the +/// setpoint by SETPOINT_STEP_C, clamped to [SETPOINT_MIN_C, SETPOINT_MAX_C]. +struct SetpointKnob { + encoder: Qei, + last_count: u16, + accum: i16, // leftover sub-detent quadrature steps + setpoint_c: f32, +} + +impl SetpointKnob { + fn new(encoder: Qei) -> Self { + let last_count = encoder.count(); + Self { + encoder, + last_count, + accum: 0, + setpoint_c: SETPOINT_DEFAULT_C, + } + } + + /// Sample the encoder, fold any rotation into the setpoint, and return it. + fn poll(&mut self) -> f32 { + let now = self.encoder.count(); + // Signed step delta, robust across the u16 counter wrapping either way. + let delta = now.wrapping_sub(self.last_count) as i16; + self.last_count = now; + self.accum += delta; + let detents = self.accum / COUNTS_PER_DETENT; + if detents != 0 { + self.accum -= detents * COUNTS_PER_DETENT; // keep the remainder + self.setpoint_c = (self.setpoint_c + detents as f32 * SETPOINT_STEP_C) + .clamp(SETPOINT_MIN_C, SETPOINT_MAX_C); + } + self.setpoint_c + } +} + #[entry] fn main() -> ! { let cp = cortex_m::Peripherals::take().unwrap(); @@ -226,12 +296,26 @@ fn main() -> ! { let led_at_temp = gpioc.pc4.into_push_pull_output(); let compass = LedCompass::new(p.GPIOD.split(&mut rcc)); + // Rotary encoder on TIM1 encoder mode: PE9 = A (CH1), PE11 = B (CH2), + // common to GND. Needs external 10k pull-ups to 3V on A/B (this HAL's QEI + // pins don't enable the internal ones). One detent = SETPOINT_STEP_C. + let gpioe = p.GPIOE.split(&mut rcc); + let encoder = Qei::new(p.TIM1, (gpioe.pe9, gpioe.pe11), &mut rcc); + let mut knob = SetpointKnob::new(encoder); + 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); + defmt::info!("kettle: setpoint {} C, brew {} s", SETPOINT_DEFAULT_C, BREW_TICKS); - let mut kettle = Kttl::new(heater, led_heating, led_at_temp, compass, button.is_high()); + let mut kettle = Kttl::new( + heater, + led_heating, + led_at_temp, + compass, + button.is_high(), + SETPOINT_DEFAULT_C, + ); loop { let raw = adc.convert(&therm, SampleTime::Cycles_480); @@ -245,6 +329,7 @@ fn main() -> ! { while waited < TICK_MS { delay.delay_ms(POLL_MS); waited += POLL_MS; + kettle.set_setpoint(knob.poll()); kettle.press(button.is_high(), 0.0); } continue; @@ -264,15 +349,16 @@ fn main() -> ! { "T={=i32}.{=u32} C set={} C {}", t_dc / 10, (t_dc % 10).unsigned_abs(), - SETPOINT_C, + kettle.setpoint(), kettle.state() ); - // ---- wait one tick, polling the on-switch for a press event ---- + // ---- wait one tick, polling the on-switch + encoder ---- let mut waited = 0; while waited < TICK_MS { delay.delay_ms(POLL_MS); waited += POLL_MS; + kettle.set_setpoint(knob.poll()); kettle.press(button.is_high(), temp_c); } }