From 5bb0cde777e2e106263999f1c0a1afb89b4d2bfd Mon Sep 17 00:00:00 2001 From: Nathan Rashleigh Date: Sun, 28 Jun 2026 15:46:42 +1000 Subject: [PATCH 1/2] setup fliplink --- .cargo/config.toml | 4 + Cargo.toml | 2 +- README.md | 0 justfile | 59 ++++++++++++ kttl-analog-wiring.md | 202 ++++++++++++++++++++++++++++++++++++++++++ kttl.sublime-project | 6 +- 6 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 README.md create mode 100644 justfile create mode 100644 kttl-analog-wiring.md diff --git a/.cargo/config.toml b/.cargo/config.toml index 654e59b..5a05785 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,10 @@ [target.'cfg(all(target_arch = "arm", target_os = "none"))'] runner = "probe-rs run --chip STM32F411VETx" +# flip-link: zero-cost stack-overflow protection. It wraps the real linker +# (LLD here), so it stays compatible with the -Tlink.x / -Tdefmt.x args below. +linker = "flip-link" + rustflags = [ # LLD (shipped with the Rust toolchain) is used as the default linker "-C", "link-arg=-Tlink.x", diff --git a/Cargo.toml b/Cargo.toml index 9f197e3..8f28893 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] # board support: re-exports the HAL as `f411::hal`, plus the L3gd20/Lsm303dlhc # interfaces and the `led` module -f411 = { path = "../f411" } +f411 = { path = "../code/f411" } cortex-m = "0.7.7" cortex-m-rt = "0.7.5" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/justfile b/justfile new file mode 100644 index 0000000..34a6b94 --- /dev/null +++ b/justfile @@ -0,0 +1,59 @@ +# kttl — STM32F411 kettle controller +# Run `just` to list recipes. + +# udev rules shipped with this repo (probe-rs probe permissions on Linux) +rules_src := "etc/69-probe-rs.rules" +rules_dst := "/etc/udev/rules.d/69-probe-rs.rules" + +# Default: show available recipes +default: + @just --list + +# One-shot dev setup: install toolchain bits + probe permissions +setup: install-tools probe-setup + @echo "Setup complete. Plug in the probe (re-plug if it was already connected)." + +# Install the Rust target and the host tools this project links/flashes with +install-tools: + rustup target add thumbv7em-none-eabihf + +# Set up probe-rs probe access on Linux: install udev rules + reload +# https://probe.rs/docs/getting-started/probe-setup/ +probe-setup: + @echo "Installing udev rules -> {{rules_dst}} (needs sudo)" + sudo install -m 0644 {{rules_src}} {{rules_dst}} + sudo udevadm control --reload + sudo udevadm trigger + @# plugdev group is referenced by the rules; create it and add the user if missing + sudo groupadd --system plugdev 2>/dev/null || true + sudo usermod -aG plugdev "$USER" + @echo "If the probe was already plugged in, unplug and reconnect it." + @echo "A new group membership requires a fresh login to take effect." + +# List debug probes probe-rs can see (verifies setup worked) +probes: + probe-rs list + +# Build the firmware (debug) +build: + cargo build + +# Build optimized +release: + cargo build --release + +# Flash + run on the target, streaming defmt logs over RTT +run: + cargo run --release + +# Compile checks / lints / format +check: + cargo check +clippy: + cargo clippy +fmt: + cargo fmt + +# Remove build artifacts +clean: + cargo clean diff --git a/kttl-analog-wiring.md b/kttl-analog-wiring.md new file mode 100644 index 0000000..4f36ee8 --- /dev/null +++ b/kttl-analog-wiring.md @@ -0,0 +1,202 @@ +# kttl — Analog Control Prototype Wiring (LM35 sensor) + +Fully-analog temperature controller for a kettle, styled after a Soviet +radiometer panel. No MCU: a comparator loop reaches/holds a rotary-selected +target, a moving-coil meter shows live temperature on a **linear 0–100 °C +dial**, and a solenoid-struck keyswitch clicks (Geiger-style) on each heater +cycle. + +Sensor is an **LM35** (10 mV/°C, linear) — chosen so the dial is truly linear +and the setpoint ladder is evenly spaced. + +> **Status:** prototype / paper design. A few values depend on measuring the +> actual meter movement and solenoid — see *Open Unknowns*. + +--- + +## 1. Panel controls + +| Ref | Control | Type | Function | Domain | +|-----|--------------------|----------------------|-------------------------------------------|-------------| +| SW1 | Rotary selector | 6-position, 1 wafer | Target temp tap (60/70/80/85/90/100 °C) | low-voltage | +| SW2 | Rocker (on/off) | SPST | **Heater enable only** (not master power) | low-voltage | +| SW3 | Hold toggle | SPDT | After target: HOLD (cycle) vs ONE-SHOT | low-voltage | +| M1 | Moving-coil meter | µA/mA movement (М205)| Live temperature, linear 0–100 °C | always-on | + +Mains is live whenever plugged in. Electronics + meter sit on an **always-on +rail**; the heater is gated downstream and switched only by the relay/SSR +(mains-last). The rocker just **stops heating** — it is not a master cut-off. + +--- + +## 2. Power tree + +``` + MAINS (live when plugged in) + ├─────────► PSU (mains→5V, always on) ──► +5V rail ─┬─► LM35, comparator, latch, + │ │ meter driver, clicker logic + │ └─► VREF_PRECISE (LM4040-2.5) + │ → setpoint ladder + │ + └─► HEATER PATH (mains): F1 fuse ─► TC1 thermal cutoff ─► K1/SSR1 ─► ELEMENT + ▲ + enable = SW2(rocker) AND logic-heat +``` + +**Note:** the ladder reference is now a **precision reference (not ratiometric)** +because the LM35 output is an absolute voltage (10 mV/°C). The 5 V rail no longer +needs to be accurate, but VREF_PRECISE does. + +--- + +## 3. Schematic (block + signal level) + +``` + +5V (always on) VREF_PRECISE = 2.500V (LM4040-2.5) + │ │ + ┌──────┴──────┐ ┌────────┴────────┐ + │ LM35 │ OUT = 10mV/°C │ SETPOINT LADDER │ + │ Vs OUT GND│ (0°C=0V, 100°C=1.00V) │ R1..R7 (taps=°C)│ + └───┬───┬───┬──┘ └────────┬────────┘ + │ │ └─GND │ taps: 0.60/0.70/0.80/ + │ │ │ 0.85/0.90/1.00 V + │ ├─────────────┬──────────────┐ ┌───────┴───────┐ + │ │ │ │ │ SW1 rotary 6-pos│ + [Rpd 18k │ │ └───────┬───────┘ + to GND] (to meter (to comparator −) │ common = selected Vref + │ driver U3) │ R_in 10k │ + GND │ │ │ + ▼ ▼ │ + ┌──────────────────────┐ │ + │ U1 COMPARATOR LM393 │ │ Rf 2.4M + │ (−) LM35 (+) tap │◄─┴──── hysteresis + │ OUT ├──┬──── (to +) ~±1°C + └──────────────────────┘ │ + OUT HIGH = cold │ on→off edge = "reached" + (pull-up 10k to +5V) │ + ┌──────────────────────────────┴──┐ + │ │ + ▼ ▼ + ┌──────────────┐ ┌────────────────┐ + │ U2 CD4013 │ │ CLICKER one-shot│ + │ D-FF latch │ │ → Q1 MOSFET │ + │ D=Hi │ │ → L1 solenoid │ + │ CLK=reached │ │ (+ D1 flyback)│ + │ CLR=POR + SW1│ │ strikes keyswitch│ + │ Q' ───┼──┐ └────────────────┘ + └──────────────┘ │ + ▼ + SW3 HOLD ──────► ┌───────────────┐ + (HOLD: force Hi; │ ENABLE gate │ + 1-SHOT: use Q') │ (AND) │ + └──────┬────────┘ + │ heater-want + SW2 rocker (series) + │ + ▼ + ┌──────────────┐ + │ Q2 ─► K1/SSR1│ switches MAINS to ELEMENT + └──────┬───────┘ (via F1 + TC1, mains-last) + ▼ + ELEMENT + + METER BRANCH: + LM35 OUT ─► U3 (op-amp + transistor current sink, Re) ─► M1 + I_meter = V_LM35 / Re (no offset: 0°C = 0V) + linear 0–100 °C dial +``` + +--- + +## 4. Component values + +### 4.1 Sensor — LM35 +- **U_LM35:** LM35 (or LM35DZ), Vs = +5 V, OUT = 10 mV/°C. + - 0 °C → 0.000 V, 60 °C → 0.600 V, 100 °C → 1.000 V. +- **Rpd:** ~18 kΩ from OUT to GND (lets the output pull down toward 0 V; basic + LM35 only *sources* current). True 0 °C reading needs a negative rail; with a + GND pull-down it reads reliably from ~+2 °C up — fine for a kettle. +- Seal the sensor against water/steam (it's a 3-pin active part, not a bead). + +### 4.2 Precision reference + setpoint ladder +- **VREF_PRECISE:** LM4040-2.5 (2.500 V shunt reference) + bias resistor from + +5 V (e.g. ~2.7 kΩ). Absolute accuracy now matters (not ratiometric). +- Ladder GND → 2.500 V; SW1 taps land on the LM35 setpoint voltages: + +| Ref | Segment | Calc. | Use (E96/std) | +|-----|----------------------|---------|---------------| +| R1 | GND → 60 °C (0.60 V) | 6.00 kΩ | 6.04 kΩ | +| R2 | 60 → 70 °C | 1.00 kΩ | 1.00 kΩ | +| R3 | 70 → 80 °C | 1.00 kΩ | 1.00 kΩ | +| R4 | 80 → 85 °C | 0.50 kΩ | 499 Ω | +| R5 | 85 → 90 °C | 0.50 kΩ | 499 Ω | +| R6 | 90 → 100 °C | 1.00 kΩ | 1.00 kΩ | +| R7 | 100 °C (1.00 V) → ref| 15.0 kΩ | 15.0 kΩ | +| RT | master cal trim | — | 500 Ω 10-turn (series w/ R7) | + +Ladder current ≈ 100 µA. Setpoint voltages = temp × 10 mV/°C — clean and even. + +### 4.3 Comparator + hysteresis +- **U1:** LM393. LM35 OUT → (−); SW1 tap → (+) via **R_in = 10 kΩ**. + OUT HIGH = cold = heater wanted. Pull-up 10 kΩ on OUT to +5 V. +- **Rf:** 2.4 MΩ from OUT to (+) → maintain band ≈ ±1 °C (R_in/Rf · Vout_swing). + Band varies slightly with selected tap impedance. + +### 4.4 Mode latch (one-shot vs hold) +- **U2:** CD4013 D flip-flop. D = HIGH. CLK = comparator on→off ("reached") edge + (RC-debounce if it chatters). CLR = power-on-reset cap **and** SW1 common + (changing temp re-arms). Q′ → enable gate. +- **SW3 (Hold):** + - HOLD → forces enable gate's 2nd input HIGH (latch bypassed → cycles forever). + - ONE-SHOT → enable uses Q′ (heater latches OFF after first reach). + - Wire SW3 to also pulse CLR so flipping to HOLD resumes cycling. + +### 4.5 Heater drive +- Enable gate (heater-want) AND **SW2 rocker** (series) → **Q2** (small MOSFET) → + relay coil **K1** (with flyback diode) *or* **SSR1** control input. +- K1/SSR1 switches mains to the element, downstream of **F1 (fuse)** and **TC1 + (independent thermal cutoff)**. + +### 4.6 Clicker +- Comparator on→off edge → one-shot pulse → **Q1** (logic-level MOSFET) → **L1** + mini push-pull solenoid (5 V, ~3.5 mm stroke, Adafruit 2776 class) + **D1** + flyback diode. Plunger strikes a tactile keyswitch (switch gives the click + + bump; pick the strike target/resonator for pitch). +- HOLD → ticks each maintain cycle; ONE-SHOT → single tick. Add jitter to the + pulse for Geiger-style randomness instead of metronomic ticking. + +### 4.7 Meter driver +- **U3:** op-amp + transistor current sink. LM35 OUT → op-amp (+); meter in the + collector/drain; Re in emitter/source. `I_meter = V_LM35 / Re`, independent of + coil resistance. **No offset stage** (0 °C already = 0 V). +- Re by movement: **1 mA → Re = 1.00 kΩ**; **100 µA → Re = 10.0 kΩ**. +- Dial is **linear 0–100 °C** — re-letter the М205 face 0→100. + +--- + +## 5. Safety notes +- **SW1/SW2/SW3 are all low-voltage.** No panel switch carries element current; + mains is switched only by K1/SSR1 (mains-last). +- **F1 fuse + TC1 thermal cutoff in series with the element are mandatory.** The + comparator/latch is *control*, not *protection* — it can't save you from a + welded relay or a failed/detached sensor. +- **Always-on rail = always-live internals.** True off is unplugging; the rocker + only stops heating. +- Keep the meter/logic rail decoupled from heater switching transients, or the + needle twitches and the latch can glitch each cycle. + +--- + +## 6. Open unknowns (measure before committing values) +1. **М205 movement:** full-scale current and coil resistance — sets Re. Measure + first; guess wrong and the needle pins or barely moves. +2. **Solenoid force/stroke** vs the chosen keyswitch (~0.5 N, ~2 mm): confirm + force *at* the working stroke (not headline force) and intermittent duty at + the fastest click rate (near boil). +3. **LM35 low-end:** reads down to ~+2 °C with a GND pull-down; true 0 °C needs a + negative supply. Decide if that matters for the dial's bottom mark. +4. **Sensor sealing/placement:** the LM35 must be waterproofed; element-mounted + (fast, overshoots) vs water-immersed (accurate, laggy) changes hysteresis feel. +5. **VREF_PRECISE accuracy** now sets setpoint accuracy directly (no ratiometric + cancellation) — use a ≤0.5% reference and the master trim RT to null offset. +``` diff --git a/kttl.sublime-project b/kttl.sublime-project index 9aa57a9..12b1333 100644 --- a/kttl.sublime-project +++ b/kttl.sublime-project @@ -2,10 +2,10 @@ "folders": [ { - "path": "." + "path": ".", }, { "path": "/home/ntr/code/f411" - } - ] + }, + ], } From f40b8becfdf5db8f7d7c7190febb788fb30d8b1e Mon Sep 17 00:00:00 2001 From: Nathan Rashleigh Date: Sun, 28 Jun 2026 20:40:29 +1000 Subject: [PATCH 2/2] rotary --- etc/rotary.md | 125 +++++++++++++++++++++++++++++++++++++++++++ kttl.sublime-project | 20 ++++++- src/main.rs | 110 ++++++++++++++++++++++++++++++++----- 3 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 etc/rotary.md 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); } }