ESP32-S3 firmware for a three-zone heating controller. Each zone pairs an NTC thermistor, a capacitive touch pad and a MOSFET-driven heating element under independent PID control, with Wi-Fi and MQTT for configuration and telemetry.
Every push and pull request builds the firmware with ESP-IDF v6.1 for
esp32s3; the badge above reflects that build, and the resulting .bin is
attached to each run as an artifact.
The image has also been flashed and run on the hardware: all three zones
regulate under PID, touch-gated sessions start and stop as described, and MQTT
telemetry publishes on heating_cube/status. Hardware behaviour is verified by
hand, not by CI — last checked against commit b722e2c.
| Zone | NTC (ADC1) | Touch pad | Heater gate |
|---|---|---|---|
| 1 | GPIO1 (CH0) | GPIO4 | GPIO7 |
| 2 | GPIO2 (CH1) | GPIO5 | GPIO8 |
| 3 | GPIO3 (CH2) | GPIO6 | GPIO9 |
Zone indices are shared across every module; the firmware uses 0-based indices internally and 1-based numbering in logs and MQTT payloads.
Thermistors are 10 kΩ NTCs (B25/85 = 3950) on a low-side divider with a 10 kΩ series resistor. Readings are median-filtered over 5 samples, then smoothed with an exponential moving average.
Heaters are driven by LEDC hardware PWM at 1 kHz, 12-bit resolution, all three sharing one timer. Gates are forced low before anything else during boot.
Touch pads use the ESP32-S3 V2 touch peripheral with hardware benchmark tracking, smoothing, hysteresis and debounce. The touch channel number equals the GPIO number on this chip.
Requires ESP-IDF v6.1 targeting esp32s3.
idf.py set-target esp32s3
idf.py menuconfig # see Configuration below
idf.py build
idf.py -p /dev/ttyUSB0 flash monitorUnder menuconfig → Heating controller:
| Option | Default | Meaning |
|---|---|---|
HEATING_WIFI_SSID |
myssid |
Wi-Fi network |
HEATING_WIFI_PASSWORD |
mypassword |
Wi-Fi passphrase |
HEATING_MQTT_URI |
mqtt://192.168.1.10:1883 |
Broker URI (mqtt:// or mqtts://) |
HEATING_MAX_TEMP_C |
120 |
Safety cut-out, °C |
PID gains are compile-time constants in main/main.c (PID_KP, PID_KI,
PID_KD). The defaults suit a small thermal mass: proportional-dominant with
a small derivative term, since a large one would amplify ADC noise into gate
chatter at the 10 Hz loop rate. Tune on the real load — raise PID_KP until
it oscillates, back off to about half, then add PID_KI until the
steady-state offset closes.
All topics are rooted at heating_cube, defined once as TOPIC_ROOT in
main/wifi_mqtt.c.
| Topic | Direction | Purpose |
|---|---|---|
heating_cube/setpoint |
in | Direct heating control |
heating_cube/commands |
in | Arm a touch-gated session |
heating_cube/setup |
in | Maintenance commands |
heating_cube/status |
out | Periodic telemetry (every 2 s) |
heating_cube/values |
out | One message per completed session |
Heats immediately, with no touch interaction.
{"temp_setpoint_c": 43, "channels": [1, 0, 1]}The channels array describes every zone: a non-zero entry heats that
zone to the given temperature, a zero entry switches it off. One message
always leaves the system in a fully known state. An array shorter than three
entries switches the zones it omits off.
Three older forms remain accepted:
{"setpoint": 43} // all zones
{"zone": 2, "setpoint": 43} // one zone, others untouched
43 // bare number, all zonesSetpoints are clamped to [0, HEATING_MAX_TEMP_C]; out-of-range values are
rejected with a log line. A setpoint of 0 switches a zone off.
Same payload format, but it arms a session instead of heating straight away. Heating begins only once all three pads are touched simultaneously, and stops the instant any pad is released. See Touch-gated sessions below.
mosquitto_pub -h broker -t heating_cube/commands \
-m '{"temp_setpoint_c":43,"channels":[1,0,1]}'Accepts a bare command name or {"command": "..."}:
| Command | Effect |
|---|---|
calibrate |
Re-measures the untouched touch baselines and derives thresholds, storing them in NVS. The pads must not be touched while this runs. |
diag |
Logs benchmark, smooth reading, deviation, threshold and active state for every pad |
mosquitto_pub -h broker -t heating_cube/setup -m 'diag'Published every 2 seconds:
{"online": true, "zones": [
{"zone": 1, "temperature": 32.92, "setpoint": 43.00, "duty": 0.412, "touch": 0},
{"zone": 2, "temperature": null, "setpoint": 0.00, "duty": 0.000, "touch": 0},
{"zone": 3, "temperature": 41.08, "setpoint": 43.00, "duty": 0.255, "touch": 1}
]}A zone with no valid reading reports "temperature": null. The broker holds a
last-will message so "online" goes false if the device drops off.
One message per completed session:
{"touch_time_ms": 12345, "temp_setpoint_c": 43.00,
"touched": [1, 1, 0], "channels": [1, 0, 1],
"temps_max_c": [40.00, 39.50, 41.00],
"temps_avg_c": [38.10, null, 39.40]}| Field | Meaning |
|---|---|
touch_time_ms |
Elapsed time from PWM start to stop |
touched |
Pad states at the instant heating stopped |
temp_setpoint_c |
The setpoint the session ran at |
channels |
Which zones the session heated |
temps_max_c |
Peak temperature per NTC over the running period |
temps_avg_c |
Mean temperature per NTC over the running period |
Temperature statistics cover all three NTCs regardless of which channels
were heated, and span the running period only. A zone with no valid reading
for the entire session reports null.
A session couples heating to a physical hold on all three pads — heat flows only while a person is in contact with every pad, and stops the moment that contact breaks.
IDLE ──(non-zero setpoint on /commands)──▶ ARMED
ARMED ──(all three pads touched)────────▶ RUNNING [PWM on, timer starts]
RUNNING ──(any pad released)────────────▶ IDLE [PWM off, publish]
RUNNING ──(zero setpoint on /commands)──▶ IDLE [PWM off, publish]
ARMED ──(zero setpoint on /commands)────▶ IDLE [no publish]
Key behaviours:
- Armed does not heat. The setpoint is held at zero until all pads are touched.
- Any release stops everything, including channels whose own pad is still
held. All active channels start and stop together, so
touch_time_msis one number rather than one per channel. - A session is consumed by running. After it stops, a fresh
/commandsmessage is required; it does not re-arm itself. - A zero setpoint cancels. Cancelling a running session still publishes its results; cancelling an armed one does not, since nothing ran.
- Sessions override direct setpoints on the channels they own. When a session ends it releases those channels and zeroes both the session and the manual setpoint, so zones return to direct control cold rather than inheriting a stale value.
Touch state is sampled once per control cycle (100 ms), so session timing has roughly decisecond resolution.
Several independent cut-outs force heaters off:
- Over-temperature — any zone reading at or above
HEATING_MAX_TEMP_Cis cut immediately and its PID reset. - Sensor failure — 10 consecutive invalid readings (about 1 second) cut that zone.
- Boot — every gate is driven low before any other initialisation.
- Setpoint range — values outside
[0, HEATING_MAX_TEMP_C]are rejected rather than clamped silently.
If Wi-Fi or MQTT fails to come up, the controller keeps regulating on the last known setpoints rather than rebooting into an uncontrolled state; the MQTT client retries on its own.
| File | Responsibility |
|---|---|
main/main.c |
Control loop, PID scheduling, session state machine, safety cut-outs |
main/wifi_mqtt.c |
Wi-Fi station, MQTT client, payload parsing and publishing |
main/touch.c |
Touch peripheral setup, calibration, threshold storage, stuck-pad recovery |
main/ntc.c |
ADC sampling, median filter, Steinhart–Hart conversion |
main/heater.c |
LEDC PWM setup and duty control |
main/pid.c |
PID with derivative-on-measurement and integral clamping |
The control loop runs at 10 Hz (CONTROL_PERIOD_MS = 100). Each cycle reads
touch and temperature for all zones, advances the session state machine, runs
one PID step per zone, and every 20th cycle publishes status.
Touch thresholds are derived from each pad's untouched baseline and stored in
NVS, so they survive reboots. Send calibrate on heating_cube/setup with
nothing touching the pads.
Calibration samples each benchmark 16 times and rejects a pad whose reading swings more than 5 % across those samples — normally a sign the pad was being touched or is disconnected. Pads can also read as unstable in the first minute after boot, before their benchmarks have settled; if a calibration is rejected that way, simply repeat it.
Use diag to see how a real press compares to the threshold:
zone 1: benchmark=68435 smooth=68440 deviation=5 threshold=13687 active=0
A press should push deviation well clear of threshold. If presses are
missed, lower TOUCH_THRESHOLD_RATIO in main/touch.c; if pads trigger from
a nearby hand or trip their neighbours, raise it. The ratio is applied to the
measured benchmark at calibration time, so any change needs a recalibration to
take effect.
The heater MOSFETs couple capacitively into the touch wiring — opto-isolation breaks the galvanic path but not the electric field, and the drain dV/dt at each PWM edge injects charge into a high-impedance touch node. If false triggers correlate with PWM activity rather than temperature, the effective fixes are physical: a gate resistor to soften dV/dt, greater separation between touch and drain wiring, a grounded shield between them, or shorter touch runs.