Skip to content

ESP32-S2 Thermostatic Control With Resistive Heating and NST112

This project implements a constant-temperature control loop on an ESP32-S2 learning kit: a resistive heater warms a small insulated area, an NST112 digital temperature sensor reports board temperature, and a 1.44\" ST7735 LCD shows a gauge-style readout. PWM drives a MOSFET heater switch; a PID-style regulator adjusts duty cycle. A shared ADC pin reads a resistor-ladder network for keys and a rotary encoder.

Assumption: Exact GPIO assignments, schematic net names, and complete User_Setup.h for TFT_eSPI match your vendor board package—treat the snippets below as patterns, not a full firmware drop-in.

Hardware overview

ESP32-S2 module board

The ESP32-S2 is a single-core Xtensa LX7 SoC focused on 2.4 GHz Wi‑Fi, rich GPIO, and USB programmability under ESP-IDF or Arduino. Typical dev boards add USB-C, buttons, LEDs, and two headers that break out usable IO.

On-module highlights (family-level):

Expansion board

The IO expansion board used here provides:

  • Keys and rotary encoder (routed through an analog resistor network).
  • Dual potentiometer inputs.
  • RGB LED.
  • 1.44\" 128×128 LCD on SPI (ST7735-class).
  • MMA7660 accelerometer.
  • Heater resistor + MOSFET stage.
  • NST112 temperature sensor (I2C).
  • Breakout to the ESP32-S2 core module.

Control concept

The heater raises board temperature when the MOSFET conducts. Firmware reads temperature, compares to a setpoint, and updates PWM duty to regulate average power. Vendor math for the heater network (illustrative): (P \approx U^2 / R_\text{eq}) with 3.3 V rails and parallel 68 Ω elements yields on the order of ~0.6 W—enough for a demo hotspot when insulated (verify on your schematic).

Toolchain

  • PlatformIO + VS Code for build, flash, and libraries.
  • A serial terminal (CoolTerm, PuTTY, PIO monitor, and so on) for debug traces.

Firmware uses the Arduino framework on ESP32-S2 for fast bring-up.

Firmware architecture

Functional blocks:

  1. Read NST112 over I2C.
  2. Render UI with TFT_eSPI (gauge-style view adapted from TFT_eSPI analog meter examples).
  3. Output LEDC PWM to the heater MOSFET gate path.
  4. Sample ADC quickly to decode encoder steps and button states from the resistor ladder.

NST112 temperature read

The NST112-DSTR is a small digital sensor with a simple register model. Below is a compact init/read pattern; 7-bit address and register bytes must match your board wiring and datasheet configuration.

#include <Arduino.h>
#include <Wire.h>

static const uint8_t kNst112Addr7 = 0x48;
static const uint8_t kRegSet = 0x01;
static const uint8_t kRegTemp = 0x00;
static const float kLsbC = 0.0625f;
static const uint8_t kNumBits = 12;

static const uint8_t kCfg0 = 0b01100000;
static const uint8_t kCfg1Base = 0b11100000;

void nst112Init() {
  Wire.begin();
  Wire.beginTransmission(kNst112Addr7);
  Wire.write(kRegSet);
  Wire.write(kCfg0);
  Wire.write(static_cast<uint8_t>(kCfg1Base | ((kNumBits == 13) ? (1u << 4) : 0)));
  Wire.endTransmission();
}

float nst112ReadC() {
  uint8_t msb = 0;
  uint8_t lsb = 0;

  Wire.beginTransmission(kNst112Addr7);
  Wire.write(kRegTemp);
  Wire.endTransmission(false);
  const uint8_t got = Wire.requestFrom(static_cast<int>(kNst112Addr7), 2, static_cast<int>(true));
  if (got == 2) {
    msb = Wire.read();
    lsb = Wire.read();
  }

  uint16_t raw = static_cast<uint16_t>((msb << 8) | lsb);
  raw >>= (kNumBits == 13) ? 3 : 4;
  return static_cast<float>(raw) * kLsbC;
}

Note: Configure conversion rate / averaging per the Novosns datasheet for your accuracy goals; the constants above follow the original coursework sketch.

LCD gauge (TFT_eSPI)

The coursework UI uses TFT_eSPI with an analog meter renderer and a moving needle (analogMeter(), plotNeedle(), bitmap assets). That code is lengthy and tightly coupled to sprite sizes and font indices—keep it in your repository and tune User_Setup.h for ST7735, SPI pins, and rotation.

Heater PWM (LEDC)

Use LEDC to generate a stable PWM for the MOSFET driver:

const int kPwmPin = /* your GPIO */;
const int kLedcChannel = 0;
const uint32_t kPwmFreqHz = 20000;   // example; pick what your gate driver filters allow
const uint8_t kResolutionBits = 12;  // 0..4095 duty span

void heaterPwmInit() {
  pinMode(kPwmPin, OUTPUT);
  ledcSetup(kLedcChannel, kPwmFreqHz, kResolutionBits);
  ledcAttachPin(kPwmPin, kLedcChannel);
}

High duty increases average heater power; low duty reduces it. Always respect safe temperature limits and insulation—demo boards are not industrial ovens.

PID-style regulation

The original project uses a position PID with integral separation to limit overshoot; in testing, PD-like tuning was often sufficient because the plant is slow and inertial.

Bugfix vs a common coursework paste: constrain() returns a value—assign it back to the target variable.

struct PositionPid {
  float Kp, Ki, Kd;
  float Integral;
  float LastErr;
  float Output;
  float OutputMin, OutputMax;
  float I_outputMin, I_outputMax;
  float Integraldead_zone;
  int index;
};

float singlePidPosition(PositionPid* pid, float target, float measure) {
  if (!pid) return 0.0f;

  const float err = target - measure;

  pid->index = (fabs(err) > pid->Integraldead_zone) ? 0 : 1;

  pid->Output = pid->Kp * err + pid->Kd * (err - pid->LastErr);
  pid->Integral += pid->Ki * err * static_cast<float>(pid->index);

  pid->Output = constrain(pid->Output, pid->OutputMin, pid->OutputMax);
  pid->Integral = constrain(pid->Integral, pid->I_outputMin, pid->I_outputMax);

  pid->Output += pid->Integral;
  pid->Output = constrain(pid->Output, pid->OutputMin, pid->OutputMax);

  pid->LastErr = err;
  return pid->Output;
}

Control loop rate

If the sensor update path is 8 Hz, run the regulator on the same cadence (125 ms) so measurement and actuation stay aligned—typically a hardware timer or a Ticker callback that:

  1. Reads temperature.
  2. Updates PID output.
  3. Writes ledcWrite(channel, duty).
  4. Refreshes the on-screen values / needle.

Serial telemetry fix: match format specifiers to arguments (original code printed two floats with a mismatched format string in one branch).

void onControlTick() {
  const float tempNow = nst112ReadC();
  float pwm = singlePidPosition(&gPid, gTargetC, tempNow);
  pwm = constrain(pwm, 0.0f, 4095.0f);

  if (!gHeaterEnabled) {
    pwm = 0.0f;
    ledcWrite(kLedcChannel, 0);
    return;
  }

  ledcWrite(kLedcChannel, static_cast<uint32_t>(pwm));

  char line[48];
  snprintf(line, sizeof(line), "{Temp}%.2f,%.2f\r\n", gTargetC, tempNow);
  Serial.print(line);
  snprintf(line, sizeof(line), "{PWM}%.0f\r\n", pwm);
  Serial.print(line);
}

Encoder and keys through one ADC

The expansion board multiplexes encoder phases and buttons into one ADC node via resistor dividers. At ~200 Hz sampling, step patterns differ for clockwise vs counter-clockwise rotation; the firmware thresholds raw codes against calibrated ADC centers.

Example ADC traces from the coursework (CW rotation, CCW rotation, encoder push, key):

Decoding logic measures baseline ADC bins (ADC_Val[] in the original), applies a tolerance window, and infers rotation direction by the sequence of minima between states. Keep calibration values in flash or EEPROM if you move between boards.

Operation

  • Encoder push: toggles heating on/off.
  • Key: selects which digit of the setpoint you edit (tens / ones / tenths in the original UX).
  • Rotate: bumps the selected digit CW/CCW.
  • When |error| < ~1 °C (stricter than an earlier ±3 °C coursework spec in the source notes), the UI shows OK and status LEDs reflect regulation state.

Challenges and tuning notes

  • ADC ladder decoding is the trickiest UX piece: you need fast, clean sampling and calibrated thresholds per board.
  • Thermal inertia is large: expect to lean on derivative action and conservative integrator windup limits; validate always with a real temperature probe if you care about absolute accuracy.

Next improvements

Polish the HMI, harden noise rejection on the ADC channel, and persist calibration constants per device.

References