rotary
This commit is contained in:
1 parent
5bb0cde777
commit
f40b8becfd
3 files changed
+242
-13
No files matched your search
+125
@@ -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.
|
||||
+19
-1
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
+98
-12
@@ -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<Output<PushPull>>, // 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<Output<PushPull>>,
|
||||
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<pac::TIM1>,
|
||||
last_count: u16,
|
||||
accum: i16, // leftover sub-detent quadrature steps
|
||||
setpoint_c: f32,
|
||||
}
|
||||
|
||||
impl SetpointKnob {
|
||||
fn new(encoder: Qei<pac::TIM1>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user