Merge branch 'master' of ssh://git.ntwl.xyz:60022/ntr/kttl
This commit is contained in:
commit
5386699f7b
8 files changed
+511
-17
No files matched your search
@@ -4,6 +4,10 @@
|
|||||||
# (hardfault catch stays on by default, so panics still produce a backtrace.)
|
# (hardfault catch stays on by default, so panics still produce a backtrace.)
|
||||||
runner = "probe-rs run --chip STM32F411VETx --no-catch-reset"
|
runner = "probe-rs run --chip STM32F411VETx --no-catch-reset"
|
||||||
|
|
||||||
|
# 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 = [
|
rustflags = [
|
||||||
# LLD (shipped with the Rust toolchain) is used as the default linker
|
# LLD (shipped with the Rust toolchain) is used as the default linker
|
||||||
"-C", "link-arg=-Tlink.x",
|
"-C", "link-arg=-Tlink.x",
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
# board support: re-exports the HAL as `f411::hal`, plus the L3gd20/Lsm303dlhc
|
# board support: re-exports the HAL as `f411::hal`, plus the L3gd20/Lsm303dlhc
|
||||||
# interfaces and the `led` module
|
# interfaces and the `led` module
|
||||||
f411 = { path = "../f411" }
|
f411 = { path = "../code/f411" }
|
||||||
|
|
||||||
cortex-m = "0.7.7"
|
cortex-m = "0.7.7"
|
||||||
cortex-m-rt = "0.7.5"
|
cortex-m-rt = "0.7.5"
|
||||||
|
|||||||
+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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
```
|
||||||
+22
-4
@@ -2,10 +2,28 @@
|
|||||||
"folders":
|
"folders":
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"path": "."
|
"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
|
//! PC2 (P1 pin 10) ──[R]──▶|──GND RED = heater on
|
||||||
//! PC4 (P1 pin 20) ──[R]──▶|──GND BLUE = at/above setpoint
|
//! PC4 (P1 pin 20) ──[R]──▶|──GND BLUE = at/above setpoint
|
||||||
//! on-board user LEDs (PD12-15) — brew timer: north LED (orange) = counting
|
//! 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_std]
|
||||||
#![no_main]
|
#![no_main]
|
||||||
@@ -48,6 +58,7 @@ use f411::{
|
|||||||
},
|
},
|
||||||
pac,
|
pac,
|
||||||
prelude::*,
|
prelude::*,
|
||||||
|
qei::Qei,
|
||||||
rcc::Config,
|
rcc::Config,
|
||||||
},
|
},
|
||||||
led::LedCompass,
|
led::LedCompass,
|
||||||
@@ -62,9 +73,17 @@ const T0: f32 = 298.15; // reference temperature for R0 (kelvin, = 25 C)
|
|||||||
const ADC_MAX: f32 = 4095.0;
|
const ADC_MAX: f32 = 4095.0;
|
||||||
|
|
||||||
// ---- Control parameters ----
|
// ---- Control parameters ----
|
||||||
// Target temperature. Low (~25 C) so it can be exercised by warming the
|
// Setpoint is selected live with the rotary encoder and clamped to a safe band.
|
||||||
// thermistor by hand; raise toward ~90-95 C for an actual kettle.
|
// Starts low (~25 C) so it can be exercised by warming the thermistor by hand;
|
||||||
const SETPOINT_C: f32 = 25.0;
|
// 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 ----
|
// ---- Timing ----
|
||||||
const TICK_MS: u32 = 1000; // control cadence (temperature read, timer advance)
|
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
|
led_at_temp: PC4<Output<PushPull>>, // blue: at/above setpoint
|
||||||
compass: LedCompass, // on-board LEDs; north = brew timer
|
compass: LedCompass, // on-board LEDs; north = brew timer
|
||||||
prev_pressed: bool, // for press edge detection
|
prev_pressed: bool, // for press edge detection
|
||||||
|
setpoint_c: f32, // target temp, set live by the encoder
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kttl {
|
impl Kttl {
|
||||||
@@ -101,6 +121,7 @@ impl Kttl {
|
|||||||
led_at_temp: PC4<Output<PushPull>>,
|
led_at_temp: PC4<Output<PushPull>>,
|
||||||
mut compass: LedCompass,
|
mut compass: LedCompass,
|
||||||
pressed: bool,
|
pressed: bool,
|
||||||
|
setpoint_c: f32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// Only the north LED is used; park the rest off.
|
// Only the north LED is used; park the rest off.
|
||||||
compass.e.off();
|
compass.e.off();
|
||||||
@@ -113,6 +134,7 @@ impl Kttl {
|
|||||||
led_at_temp,
|
led_at_temp,
|
||||||
compass,
|
compass,
|
||||||
prev_pressed: pressed,
|
prev_pressed: pressed,
|
||||||
|
setpoint_c,
|
||||||
};
|
};
|
||||||
kettle.apply_outputs(0.0);
|
kettle.apply_outputs(0.0);
|
||||||
kettle
|
kettle
|
||||||
@@ -122,6 +144,16 @@ impl Kttl {
|
|||||||
self.state
|
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.
|
/// Per-tick, temperature/timer-driven transitions.
|
||||||
fn tick(&mut self, temp_c: f32) {
|
fn tick(&mut self, temp_c: f32) {
|
||||||
self.state = match self.state {
|
self.state = match self.state {
|
||||||
@@ -129,7 +161,7 @@ impl Kttl {
|
|||||||
defmt::info!("thermistor reconnected");
|
defmt::info!("thermistor reconnected");
|
||||||
State::Idle
|
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);
|
defmt::info!("setpoint reached — switched OFF, brew {} s", BREW_TICKS);
|
||||||
State::Brewing { remaining: BREW_TICKS }
|
State::Brewing { remaining: BREW_TICKS }
|
||||||
}
|
}
|
||||||
@@ -161,7 +193,7 @@ impl Kttl {
|
|||||||
fn press(&mut self, pressed: bool, temp_c: f32) {
|
fn press(&mut self, pressed: bool, temp_c: f32) {
|
||||||
if pressed && !self.prev_pressed {
|
if pressed && !self.prev_pressed {
|
||||||
match self.state {
|
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.state = State::Heating;
|
||||||
self.apply_outputs(temp_c);
|
self.apply_outputs(temp_c);
|
||||||
defmt::info!("switched ON");
|
defmt::info!("switched ON");
|
||||||
@@ -181,19 +213,19 @@ impl Kttl {
|
|||||||
State::Heating => {
|
State::Heating => {
|
||||||
self.heater.set_high();
|
self.heater.set_high();
|
||||||
self.led_heating.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();
|
self.compass.n.off();
|
||||||
}
|
}
|
||||||
State::Brewing { .. } => {
|
State::Brewing { .. } => {
|
||||||
self.heater.set_low();
|
self.heater.set_low();
|
||||||
self.led_heating.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();
|
self.compass.n.on();
|
||||||
}
|
}
|
||||||
State::Idle => {
|
State::Idle => {
|
||||||
self.heater.set_low();
|
self.heater.set_low();
|
||||||
self.led_heating.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();
|
self.compass.n.off();
|
||||||
}
|
}
|
||||||
State::Fault => {
|
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]
|
#[entry]
|
||||||
fn main() -> ! {
|
fn main() -> ! {
|
||||||
let cp = cortex_m::Peripherals::take().unwrap();
|
let cp = cortex_m::Peripherals::take().unwrap();
|
||||||
@@ -226,12 +296,26 @@ fn main() -> ! {
|
|||||||
let led_at_temp = gpioc.pc4.into_push_pull_output();
|
let led_at_temp = gpioc.pc4.into_push_pull_output();
|
||||||
let compass = LedCompass::new(p.GPIOD.split(&mut rcc));
|
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 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_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 {
|
loop {
|
||||||
let raw = adc.convert(&therm, SampleTime::Cycles_480);
|
let raw = adc.convert(&therm, SampleTime::Cycles_480);
|
||||||
@@ -245,6 +329,7 @@ fn main() -> ! {
|
|||||||
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.set_setpoint(knob.poll());
|
||||||
kettle.press(button.is_high(), 0.0);
|
kettle.press(button.is_high(), 0.0);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -264,15 +349,16 @@ fn main() -> ! {
|
|||||||
"T={=i32}.{=u32} C set={} C {}",
|
"T={=i32}.{=u32} C set={} C {}",
|
||||||
t_dc / 10,
|
t_dc / 10,
|
||||||
(t_dc % 10).unsigned_abs(),
|
(t_dc % 10).unsigned_abs(),
|
||||||
SETPOINT_C,
|
kettle.setpoint(),
|
||||||
kettle.state()
|
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;
|
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.set_setpoint(knob.poll());
|
||||||
kettle.press(button.is_high(), temp_c);
|
kettle.press(button.is_high(), temp_c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user