← All projects

ISCInstrumentation & Signal Conditioning

Programmable Thermal Chamber for Accelerated Material Testing

A benchtop chamber (20–80 °C) for polymer-degradation testing and sample incubation, with a two-phase (pre-heat/hold) closed-loop algorithm to minimize overshoot, a capacitive touchscreen UI, dual-channel CCT lighting for time-lapse imaging, and software over-temp safety.

  • Arduino Nano 33 IoT
  • PT100 RTD + MAX31865
  • ILI9341 TFT
  • MOSFET Drivers
  • Fusion 360

Hardware & Architecture

  • Arduino Nano 33 IoT running a two-phase (pre-heat and hold) closed-loop heating algorithm.
  • PT100 RTD read through an Adafruit MAX31865 amplifier over SPI for accurate temperature acquisition.
  • A 2.8" capacitive touchscreen (Adafruit ILI9341 + FT6206) with tabbed navigation, a real-time dial, and live heater status.
  • Dual-channel CCT LED lighting via MOSFET PWM drivers for adjustable warm/cool mix; relay-driven heater with software over-temp shutdown.

Key Highlights

  • Two-phase algorithm pre-heats to a computed cut margin, then holds within a tight band to minimize overshoot.
  • Hard 80 °C safety limit with automatic heater shutdown for unattended long-duration runs.
  • Interactive touchscreen dial + knob-smoothed setpoint and brightness controls.

Images

Code

thermal_chamber.inoChamber Firmware (TFT UI + PT100 + heater/LED control)cpp
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <Adafruit_FT6206.h>
#include <Adafruit_MAX31865.h>
#include <math.h>

// ======================================================
// IMPORTANT CONFIG VARIABLES
// ======================================================

// ---------- LED brightness knob (A2) ----------
const float LED_KNOB_ALPHA = 0.04f;   // 0..1, lower = smoother
const int LED_KNOB_MIN_DELTA_P = 2;   // % change before UI update
const int LED_MIN_ON_PERCENT = 10;    // below this LEDs forced off
const int TINT_STEP_PERCENT = 10;     // W+/C+ step size

// ---------- Temperature control ----------
const float TEMP_MIN_C = 20.0f;       // fixed min setpoint
const float MAX_TEMP_C = 80.0f;       // hard safety limit

// TEMP knob filtering (A3)
const float TEMP_KNOB_ALPHA = 0.12f;
const float TEMP_KNOB_MIN_DELTA_C = 0.25f; // deg C change before UI update

// ---------- Warm-up & hold tuning (software-only overshoot control) ----------
const float PREHEAT_FRACTION = 0.40f;   // portion of rise used as early-cut margin
const float PREHEAT_MARGIN_MIN = 3.0f;  // deg C, at least this early
const float PREHEAT_MARGIN_MAX = 10.0f; // deg C, at most this early
const float HOLD_BAND_C = 0.8f;         // deg C half-band in HOLD phase
const float TEMP_CAL_OFFSET_C = 5.0f;   // deg C

// Temperature polling period
const unsigned long TEMP_PERIOD_MS = 500;

// Relay polarity
const bool RELAY_ACTIVE_LOW = false; // true if relay is active-LOW

// ======================================================
// SHARED UI: TABS
// ======================================================
const int16_t TAB_HEIGHT = 34;
const uint8_t TAB_TEXT_SIZE = 3;

// ======================================================
// TEMP PAGE UI PARAMETERS
// ======================================================
const int16_t TEMP_DIAL_CENTER_X = 160;
const int16_t TEMP_DIAL_CENTER_Y = 143;
const int16_t TEMP_DIAL_RADIUS_OUTER = 70;
const int16_t TEMP_DIAL_RADIUS_INNER = 40;
const float TEMP_DIAL_START_DEG = -140.0f;
const float TEMP_DIAL_END_DEG = 140.0f;

// Center text area
const uint8_t TEMP_CENTER_TEXT_SIZE = 3;
const int16_t TEMP_CENTER_TEXT_AREA_X = TEMP_DIAL_CENTER_X - 50;
const int16_t TEMP_CENTER_TEXT_AREA_Y = TEMP_DIAL_CENTER_Y - 0;
const int16_t TEMP_CENTER_TEXT_AREA_W = 100;
const int16_t TEMP_CENTER_TEXT_AREA_H = 60;

// Heater status label
const int16_t TEMP_STATUS_TEXT_X = 10;
const int16_t TEMP_STATUS_TEXT_Y = TAB_HEIGHT + 10;
const int16_t TEMP_STATUS_TEXT_W = 160;
const int16_t TEMP_STATUS_TEXT_H = 18;
const uint8_t TEMP_STATUS_TEXT_SIZE = 2;

// "Set:" label under dial
const int16_t TEMP_SET_LABEL_X = 190;
const int16_t TEMP_SET_LABEL_Y = TAB_HEIGHT + 10;
const int16_t TEMP_SET_LABEL_W = 240;
const int16_t TEMP_SET_LABEL_H = 20;
const uint8_t TEMP_SET_LABEL_SIZE = 2;

// TEMP page buttons at bottom
const int16_t TEMP_BTN_HEIGHT = 36;
const int16_t TEMP_BTN_RADIUS = 10;
const uint8_t TEMP_BTN_TEXT_SIZE = 2;
const int16_t TEMP_BTN_MARGIN_X = 10;
const int16_t TEMP_BTN_MARGIN_Y = 15;

// TEMP dial decorative ticks
const uint8_t TEMP_TICK_COUNT = 48;
const int16_t TEMP_TICK_INNER_R = TEMP_DIAL_RADIUS_OUTER - 6;
const int16_t TEMP_TICK_OUTER_R = TEMP_DIAL_RADIUS_OUTER - 2;
const uint16_t TEMP_TICK_COLOR = ILI9341_DARKGREY;

// ======================================================
// LED PAGE UI PARAMETERS
// ======================================================
const uint8_t LED_TITLE_TEXT_SIZE = 2;
const int16_t LED_TITLE_X = 10;
const int16_t LED_TITLE_Y = TAB_HEIGHT + 6;

// Preset buttons
const int16_t PRESET_BTN_Y = 60;
const int16_t PRESET_BTN_W = 80;
const int16_t PRESET_BTN_H = 28;
const int16_t PRESET_BTN_RADIUS = 6;
const uint8_t PRESET_BTN_TEXT_SIZE = 2;
const int16_t PRESET_BTN_MARGIN_X = 30;

// Brightness label/value
const uint8_t LED_LABEL_TEXT_SIZE = 2;
const int16_t LED_BRIGHT_LABEL_X = 30;
const int16_t LED_BRIGHT_LABEL_Y = 105;
const int16_t LED_BRIGHT_VALUE_X = 180;
const int16_t LED_BRIGHT_VALUE_Y = 105;

// WW/CW label/value
const int16_t LED_MIX_LABEL_X = 30;
const int16_t LED_MIX_LABEL_Y = 135;
const int16_t LED_MIX_VALUE_X = 180;
const int16_t LED_MIX_VALUE_Y = 135;

// Tint buttons
const int16_t TINT_BTN_Y = 170;
const int16_t TINT_BTN_W = 110;
const int16_t TINT_BTN_H = 30;
const int16_t TINT_BTN_RADIUS = 6;
const uint8_t TINT_BTN_TEXT_SIZE = 2;

// Info text
const uint8_t LED_INFO_TEXT_SIZE = 1;
const int16_t LED_INFO_TEXT_X = 50;
const int16_t LED_INFO_TEXT_Y = 210;

// ======================================================
// PIN DEFINITIONS
// ======================================================
#define TFT_CS 10
#define TFT_DC 8
#define TFT_RST 9
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
Adafruit_FT6206 ctp = Adafruit_FT6206();

#define TOUCH_MIN_X 1
#define TOUCH_MAX_X 237
#define TOUCH_MIN_Y 4
#define TOUCH_MAX_Y 319

// MAX31865 (3-wire PT100)
#define MAX31865_CS 7
Adafruit_MAX31865 thermo = Adafruit_MAX31865(MAX31865_CS);

// Outputs
#define PIN_WARM_LED 6    // warm MOSFET
#define PIN_COOL_LED 5    // cool MOSFET
#define PIN_FAN 3         // fan MOSFET (not auto yet)
#define PIN_RELAY_HEAT 2  // heater relay

// Knobs
#define POT_LED A2   // LED brightness
#define POT_TEMP A3  // temp setpoint

// ======================================================
// COLORS
// ======================================================
const uint16_t COL_BG = ILI9341_BLACK;
const uint16_t COL_FG = ILI9341_WHITE;
const uint16_t COL_ACCENT = ILI9341_BLUE;
const uint16_t COL_DIM = ILI9341_DARKGREY;
const uint16_t COL_WARN = ILI9341_ORANGE;
const uint16_t COL_ERROR = ILI9341_RED;

// ======================================================
// SMALL TYPES & GLOBAL STATE
// ======================================================
struct Button { int16_t x, y, w, h; };
enum Page : uint8_t { PAGE_TEMP = 0, PAGE_LED = 1 };
Page currentPage = PAGE_TEMP;
Button tabTemp; Button tabLED;

// TEMP page buttons
Button btnSetTarget; Button btnHeatToggle;

// LED page buttons
Button btnPresetWarm; Button btnPresetNeutral; Button btnPresetCool;
Button btnTintWarmer; Button btnTintCooler;

// Temperature state
float currentTempC = 0.0f;
float lastTempCShown = -999.0f;
float targetTempC = 40.0f;
float knobTempPreviewC = 40.0f;
bool heaterEnabled = false;
bool heaterOn = false;
bool adjustingSetTemp = false;
unsigned long lastTempReadMs = 0;

// Heater status for label
enum HeaterStatus : uint8_t { HS_OFF, HS_IDLE, HS_HEATING, HS_OVERTEMP };
HeaterStatus lastHeaterStatus = HS_OFF;

// Warm-up vs hold phase
enum HeatMode : uint8_t { HM_STARTUP = 0, HM_HOLD = 1 };
HeatMode heatMode = HM_STARTUP;
float preheatCutTemp = 0.0f;

// Knob smoothing
float tempKnobEMA = -1.0f;
float ledKnobEMA = -1.0f;

// LED state
int brightnessPercent = 0; // start OFF
int lastBrightnessPercent = -1;
uint8_t warmPercent = 100;
uint8_t coolPercent = 0;

// ======================================================
// UTILITY FUNCTIONS
// ======================================================
bool pointInButton(const Button &b, int16_t x, int16_t y) {
  return (x >= b.x && x <= (b.x + b.w) && y >= b.y && y <= (b.y + b.h));
}

bool pointInDialCenter(int16_t x, int16_t y) {
  int16_t dx = x - TEMP_DIAL_CENTER_X;
  int16_t dy = y - TEMP_DIAL_CENTER_Y;
  int32_t r2 = dx * dx + dy * dy;
  int32_t inner2 = (TEMP_DIAL_RADIUS_INNER - 5) * (TEMP_DIAL_RADIUS_INNER - 5);
  return (r2 <= inner2);
}

void heaterWrite(bool on) {
  heaterOn = on;
  if (RELAY_ACTIVE_LOW) {
    digitalWrite(PIN_RELAY_HEAT, on ? LOW : HIGH);
  } else {
    digitalWrite(PIN_RELAY_HEAT, on ? HIGH : LOW);
  }
}

// Map temperature to dial angle (radians)
float tempToAngle(float temp) {
  float t = temp;
  if (t < TEMP_MIN_C) t = TEMP_MIN_C;
  if (t > MAX_TEMP_C) t = MAX_TEMP_C;
  float frac = (t - TEMP_MIN_C) / (MAX_TEMP_C - TEMP_MIN_C);
  frac = constrain(frac, 0.0f, 1.0f);
  float startRad = TEMP_DIAL_START_DEG * PI / 180.0f;
  float endRad = TEMP_DIAL_END_DEG * PI / 180.0f;
  return startRad + frac * (endRad - startRad);
}

// Touch mapping (using working calibration)
bool getTouchPoint(int16_t &sx, int16_t &sy) {
  if (!ctp.touched()) return false;
  TS_Point p = ctp.getPoint();
  sx = map(p.y, TOUCH_MIN_Y, TOUCH_MAX_Y, 320, 0);
  sy = map(p.x, TOUCH_MIN_X, TOUCH_MAX_X, 0, 240);
  sx = constrain(sx, 0, 319);
  sy = constrain(sy, 0, 239);
  return true;
}

// Center text inside a button
void drawButtonLabelCentered(const Button &b, const char *label, uint8_t textSize, uint16_t textColor, uint16_t bgColor) {
  tft.setTextSize(textSize);
  tft.setTextColor(textColor, bgColor);
  int16_t textWidth = strlen(label) * 6 * textSize;
  int16_t textHeight = 8 * textSize;
  int16_t tx = b.x + (b.w - textWidth) / 2;
  int16_t ty = b.y + (b.h - textHeight) / 2 + 1;
  tft.setCursor(tx, ty);
  tft.print(label);
}

// ======================================================
// TABS
// ======================================================
void drawTabs() {
  int16_t w = tft.width();
  tabTemp = { 0, 0, w / 2, TAB_HEIGHT };
  tabLED = { w / 2, 0, w / 2, TAB_HEIGHT };

  uint16_t fillTemp = (currentPage == PAGE_TEMP) ? COL_ACCENT : COL_DIM;
  tft.fillRect(tabTemp.x, tabTemp.y, tabTemp.w, tabTemp.h, fillTemp);
  tft.setTextSize(TAB_TEXT_SIZE);
  tft.setTextColor(COL_BG, fillTemp);
  tft.setCursor(tabTemp.x + 50, tabTemp.y + 6);
  tft.print("TEMP");

  uint16_t fillLED = (currentPage == PAGE_LED) ? COL_ACCENT : COL_DIM;
  tft.fillRect(tabLED.x, tabLED.y, tabLED.w, tabLED.h, fillLED);
  tft.setTextColor(COL_BG, fillLED);
  tft.setCursor(tabLED.x + 50, tabLED.y + 6);
  tft.print("LED");

  tft.drawFastHLine(0, TAB_HEIGHT, w, COL_FG);
}

// ======================================================
// TEMP PAGE: DIAL + LABELS
// ======================================================
void drawDialBase() {
  // Clear area
  tft.fillCircle(TEMP_DIAL_CENTER_X, TEMP_DIAL_CENTER_Y, TEMP_DIAL_RADIUS_OUTER + 2, COL_BG);
  // Outer ring
  tft.fillCircle(TEMP_DIAL_CENTER_X, TEMP_DIAL_CENTER_Y, TEMP_DIAL_RADIUS_OUTER, COL_DIM);
  // Inner circle
  tft.fillCircle(TEMP_DIAL_CENTER_X, TEMP_DIAL_CENTER_Y, TEMP_DIAL_RADIUS_INNER, COL_BG);

  // Decorative ticks around full circle
  for (uint8_t i = 0; i < TEMP_TICK_COUNT; i++) {
    float angle = (2.0f * PI * i) / (float)TEMP_TICK_COUNT;
    float ca = cos(angle);
    float sa = sin(angle);
    int16_t x1 = TEMP_DIAL_CENTER_X + (int16_t)(ca * TEMP_TICK_INNER_R);
    int16_t y1 = TEMP_DIAL_CENTER_Y + (int16_t)(sa * TEMP_TICK_INNER_R);
    int16_t x2 = TEMP_DIAL_CENTER_X + (int16_t)(ca * TEMP_TICK_OUTER_R);
    int16_t y2 = TEMP_DIAL_CENTER_Y + (int16_t)(sa * TEMP_TICK_OUTER_R);
    tft.drawLine(x1, y1, x2, y2, TEMP_TICK_COLOR);
  }

  // Cut bottom 30%
  int16_t cutY = TEMP_DIAL_CENTER_Y + (int16_t)(TEMP_DIAL_RADIUS_OUTER * 0.3f);
  int16_t cutH = TEMP_DIAL_CENTER_Y + TEMP_DIAL_RADIUS_OUTER - cutY + 4;
  tft.fillRect(TEMP_DIAL_CENTER_X - TEMP_DIAL_RADIUS_OUTER - 4, cutY, TEMP_DIAL_RADIUS_OUTER * 2 + 8, cutH, COL_BG);

  // Clean center
  tft.fillCircle(TEMP_DIAL_CENTER_X, TEMP_DIAL_CENTER_Y, TEMP_DIAL_RADIUS_INNER - 2, COL_BG);
}

void updateCenterTemp(bool force = false) {
  if (!force && fabs(currentTempC - lastTempCShown) < 0.1f) return;
  tft.setTextSize(TEMP_CENTER_TEXT_SIZE);
  tft.setTextColor(COL_FG, COL_BG);
  int val = (int)round(currentTempC);
  int16_t xOffset = (val >= 100) ? -30 : -20;
  int16_t curX = TEMP_DIAL_CENTER_X + xOffset;
  int16_t curY = TEMP_DIAL_CENTER_Y - 12;
  tft.setCursor(curX, curY);
  tft.print(val);
  // Small "C"
  tft.setTextSize(2);
  tft.setCursor(TEMP_DIAL_CENTER_X + 20, TEMP_DIAL_CENTER_Y - 5);
  tft.print("C");
  lastTempCShown = currentTempC;
}

void clearSetLabel() {
  tft.fillRect(TEMP_SET_LABEL_X, TEMP_SET_LABEL_Y, TEMP_SET_LABEL_W, TEMP_SET_LABEL_H, COL_BG);
}

void drawSetLabel() {
  clearSetLabel();
  tft.setTextSize(TEMP_SET_LABEL_SIZE);
  tft.setTextColor(COL_FG, COL_BG);
  tft.setCursor(TEMP_SET_LABEL_X, TEMP_SET_LABEL_Y);
  tft.print("Set: ");
  tft.print(knobTempPreviewC, 1);
  tft.print("C");
}

void updateHeaterStatusLabel(bool force = false) {
  HeaterStatus newStatus;
  if (currentTempC >= MAX_TEMP_C) newStatus = HS_OVERTEMP;
  else if (!heaterEnabled) newStatus = HS_OFF;
  else if (heaterOn) newStatus = HS_HEATING;
  else newStatus = HS_IDLE;

  if (!force && newStatus == lastHeaterStatus) return;
  lastHeaterStatus = newStatus;

  tft.fillRect(TEMP_STATUS_TEXT_X, TEMP_STATUS_TEXT_Y, TEMP_STATUS_TEXT_W, TEMP_STATUS_TEXT_H, COL_BG);
  tft.setTextSize(TEMP_STATUS_TEXT_SIZE);
  uint16_t color = COL_DIM;
  const char *text = "";
  switch (newStatus) {
    case HS_OVERTEMP: color = COL_ERROR; text = "OVERTEMP"; break;
    case HS_OFF: color = COL_DIM; text = "Heater Off"; break;
    case HS_IDLE: color = COL_DIM; text = "Idle"; break;
    case HS_HEATING: color = COL_WARN; text = "Heating"; break;
  }
  tft.setTextColor(color, COL_BG);
  tft.setCursor(TEMP_STATUS_TEXT_X, TEMP_STATUS_TEXT_Y);
  tft.print(text);
}

// ======================================================
// TEMP PAGE: STATIC + DYNAMIC
// ======================================================
void drawTempPageStatic() {
  tft.fillScreen(COL_BG);
  drawTabs();
  // Dial
  drawDialBase();
  lastTempCShown = -999.0f;
  updateCenterTemp(true);
  updateHeaterStatusLabel(true);
  clearSetLabel();

  // Buttons
  int16_t w = tft.width();
  int16_t btnY = tft.height() - TEMP_BTN_HEIGHT - TEMP_BTN_MARGIN_Y;
  int16_t btnW = (w - 30) / 2;
  btnSetTarget = { TEMP_BTN_MARGIN_X, btnY, btnW, TEMP_BTN_HEIGHT };
  btnHeatToggle = { TEMP_BTN_MARGIN_X + btnW + 10, btnY, btnW, TEMP_BTN_HEIGHT };

  // SET TARGET button
  tft.fillRoundRect(btnSetTarget.x, btnSetTarget.y, btnSetTarget.w, btnSetTarget.h, TEMP_BTN_RADIUS, COL_DIM);
  drawButtonLabelCentered(btnSetTarget, "SET TARGET", TEMP_BTN_TEXT_SIZE, COL_FG, COL_DIM);

  // HEAT toggle button
  uint16_t heatFill = heaterEnabled ? COL_WARN : COL_DIM;
  tft.fillRoundRect(btnHeatToggle.x, btnHeatToggle.y, btnHeatToggle.w, btnHeatToggle.h, TEMP_BTN_RADIUS, heatFill);
  drawButtonLabelCentered(btnHeatToggle, heaterEnabled ? "HEAT ON" : "HEAT OFF", TEMP_BTN_TEXT_SIZE, COL_BG, heatFill);
}

void updateTempPageDynamic(bool force = false) {
  updateCenterTemp(force);
  updateHeaterStatusLabel(force);
  uint16_t heatFill = heaterEnabled ? COL_WARN : COL_DIM;
  tft.fillRoundRect(btnHeatToggle.x, btnHeatToggle.y, btnHeatToggle.w, btnHeatToggle.h, TEMP_BTN_RADIUS, heatFill);
  drawButtonLabelCentered(btnHeatToggle, heaterEnabled ? "HEAT ON" : "HEAT OFF", TEMP_BTN_TEXT_SIZE, COL_BG, heatFill);
}

// ======================================================
// TEMPERATURE SENSOR + HEATER CONTROL
// ======================================================
void readTemperature() {
  (void)thermo.readRTD();
  currentTempC = thermo.temperature(1000.0, 4300.0) + TEMP_CAL_OFFSET_C;
}

// Compute where to cut off during the first warm-up
void recomputePreheatCutTemp() {
  float delta = targetTempC - currentTempC;
  if (delta <= 0.0f) {
    preheatCutTemp = targetTempC;
    heatMode = HM_HOLD;
    return;
  }
  float margin = delta * PREHEAT_FRACTION;
  if (margin < PREHEAT_MARGIN_MIN) margin = PREHEAT_MARGIN_MIN;
  if (margin > PREHEAT_MARGIN_MAX) margin = PREHEAT_MARGIN_MAX;
  preheatCutTemp = targetTempC - margin;
  heatMode = HM_STARTUP;
}

void updateHeaterControl() {
  bool overtemp = (currentTempC >= MAX_TEMP_C);
  if (!heaterEnabled || overtemp) {
    heaterWrite(false);
    heatMode = HM_STARTUP;
    return;
  }
  // PHASE 1: STARTUP
  if (heatMode == HM_STARTUP) {
    if (currentTempC < preheatCutTemp) {
      heaterWrite(true);
    } else {
      heaterWrite(false);
      heatMode = HM_HOLD;
    }
    return;
  }
  // PHASE 2: HOLD
  if (heatMode == HM_HOLD) {
    if (!heaterOn && currentTempC <= (targetTempC - HOLD_BAND_C)) {
      heaterWrite(true);
    } else if (heaterOn && currentTempC >= (targetTempC + HOLD_BAND_C)) {
      heaterWrite(false);
    }
  }
}

// ======================================================
// TEMP KNOB HANDLING
// ======================================================
void updateTempPreviewFromKnob() {
  int raw = analogRead(POT_TEMP);
  if (tempKnobEMA < 0.0f) tempKnobEMA = raw;
  tempKnobEMA = tempKnobEMA * (1.0f - TEMP_KNOB_ALPHA) + raw * TEMP_KNOB_ALPHA;
  float t = TEMP_MIN_C + (MAX_TEMP_C - TEMP_MIN_C) * (tempKnobEMA / 1023.0f);
  t = roundf(t * 2.0f) / 2.0f;
  if (fabs(t - knobTempPreviewC) < TEMP_KNOB_MIN_DELTA_C) return;
  knobTempPreviewC = t;
  if (adjustingSetTemp && currentPage == PAGE_TEMP) {
    drawSetLabel();
  }
}

void captureTargetFromPreview() {
  targetTempC = knobTempPreviewC;
  adjustingSetTemp = false;
  clearSetLabel();
  recomputePreheatCutTemp();
  drawTempPageStatic();
  updateTempPageDynamic(true);
}

// ======================================================
// LED PAGE: DRAWING
// ======================================================
void drawPresetButtons() {
  bool isWarmPreset = (warmPercent == 100 && coolPercent == 0);
  bool isNeutralPreset = (warmPercent == 50 && coolPercent == 50);
  bool isCoolPreset = (warmPercent == 0 && coolPercent == 100);

  uint16_t warmColor = isWarmPreset ? COL_ACCENT : COL_DIM;
  tft.fillRoundRect(btnPresetWarm.x, btnPresetWarm.y, btnPresetWarm.w, btnPresetWarm.h, PRESET_BTN_RADIUS, warmColor);
  drawButtonLabelCentered(btnPresetWarm, "WARM", PRESET_BTN_TEXT_SIZE, COL_BG, warmColor);

  uint16_t neutralColor = isNeutralPreset ? COL_ACCENT : COL_DIM;
  tft.fillRoundRect(btnPresetNeutral.x, btnPresetNeutral.y, btnPresetNeutral.w, btnPresetNeutral.h, PRESET_BTN_RADIUS, neutralColor);
  drawButtonLabelCentered(btnPresetNeutral, "NEUT", PRESET_BTN_TEXT_SIZE, COL_BG, neutralColor);

  uint16_t coolColor = isCoolPreset ? COL_ACCENT : COL_DIM;
  tft.fillRoundRect(btnPresetCool.x, btnPresetCool.y, btnPresetCool.w, btnPresetCool.h, PRESET_BTN_RADIUS, coolColor);
  drawButtonLabelCentered(btnPresetCool, "COOL", PRESET_BTN_TEXT_SIZE, COL_BG, coolColor);
}

void drawTintButtons() {
  tft.fillRoundRect(btnTintWarmer.x, btnTintWarmer.y, btnTintWarmer.w, btnTintWarmer.h, TINT_BTN_RADIUS, COL_DIM);
  drawButtonLabelCentered(btnTintWarmer, "W+", TINT_BTN_TEXT_SIZE, COL_BG, COL_DIM);

  tft.fillRoundRect(btnTintCooler.x, btnTintCooler.y, btnTintCooler.w, btnTintCooler.h, TINT_BTN_RADIUS, COL_DIM);
  drawButtonLabelCentered(btnTintCooler, "C+", TINT_BTN_TEXT_SIZE, COL_BG, COL_DIM);
}

void drawLEDPageStatic() {
  tft.fillScreen(COL_BG);
  drawTabs();
  tft.setTextSize(LED_TITLE_TEXT_SIZE);
  tft.setTextColor(COL_ACCENT, COL_BG);
  tft.setCursor(LED_TITLE_X, LED_TITLE_Y);

  btnPresetWarm = { PRESET_BTN_MARGIN_X, PRESET_BTN_Y, PRESET_BTN_W, PRESET_BTN_H };
  btnPresetNeutral = { PRESET_BTN_MARGIN_X + PRESET_BTN_W + 10, PRESET_BTN_Y, PRESET_BTN_W, PRESET_BTN_H };
  btnPresetCool = { PRESET_BTN_MARGIN_X + 2 * (PRESET_BTN_W + 10), PRESET_BTN_Y, PRESET_BTN_W, PRESET_BTN_H };
  drawPresetButtons();

  tft.setTextSize(LED_LABEL_TEXT_SIZE);
  tft.setTextColor(COL_FG, COL_BG);
  tft.setCursor(LED_BRIGHT_LABEL_X, LED_BRIGHT_LABEL_Y);
  tft.print("Brightness:");

  tft.setCursor(LED_MIX_LABEL_X, LED_MIX_LABEL_Y);
  tft.print("WW / CW:");

  int16_t screenW = tft.width();
  btnTintWarmer = { 20, TINT_BTN_Y, TINT_BTN_W, TINT_BTN_H };
  btnTintCooler = { screenW - TINT_BTN_W - 20, TINT_BTN_Y, TINT_BTN_W, TINT_BTN_H };
  drawTintButtons();

  tft.setTextSize(LED_INFO_TEXT_SIZE);
  tft.setTextColor(COL_DIM, COL_BG);
  tft.setCursor(LED_INFO_TEXT_X, LED_INFO_TEXT_Y);
  lastBrightnessPercent = -1;
}

void updateLEDPageDynamic(bool force = false) {
  if (force || brightnessPercent != lastBrightnessPercent) {
    tft.fillRect(LED_BRIGHT_VALUE_X, LED_BRIGHT_VALUE_Y, 80, 18, COL_BG);
    tft.setTextSize(LED_LABEL_TEXT_SIZE);
    tft.setTextColor(COL_FG, COL_BG);
    tft.setCursor(LED_BRIGHT_VALUE_X, LED_BRIGHT_VALUE_Y);
    tft.print(brightnessPercent);
    tft.print("%");
    lastBrightnessPercent = brightnessPercent;
  }
  tft.fillRect(LED_MIX_VALUE_X, LED_MIX_VALUE_Y, 140, 18, COL_BG);
  tft.setTextSize(LED_LABEL_TEXT_SIZE);
  tft.setTextColor(COL_FG, COL_BG);
  tft.setCursor(LED_MIX_VALUE_X, LED_MIX_VALUE_Y);
  tft.print(warmPercent);
  tft.print("% / ");
  tft.print(coolPercent);
  tft.print("%");
  drawPresetButtons();
}

// ======================================================
// LED LOGIC
// ======================================================
void applyLEDOutputs() {
  if (brightnessPercent < LED_MIN_ON_PERCENT) {
    analogWrite(PIN_WARM_LED, 0);
    analogWrite(PIN_COOL_LED, 0);
    return;
  }
  float b = brightnessPercent / 100.0f;
  b = constrain(b, 0.0f, 1.0f);
  uint8_t warmPWM = (uint8_t)(255.0f * b * (warmPercent / 100.0f) + 0.5f);
  uint8_t coolPWM = (uint8_t)(255.0f * b * (coolPercent / 100.0f) + 0.5f);
  analogWrite(PIN_WARM_LED, warmPWM);
  analogWrite(PIN_COOL_LED, coolPWM);
}

void updateBrightnessFromKnob() {
  int raw = analogRead(POT_LED);
  if (ledKnobEMA < 0.0f) ledKnobEMA = raw;
  ledKnobEMA = ledKnobEMA * (1.0f - LED_KNOB_ALPHA) + raw * LED_KNOB_ALPHA;
  int mapped = map((int)round(ledKnobEMA), 0, 1023, 0, 100);
  mapped = constrain(mapped, 0, 100);
  if (abs(mapped - brightnessPercent) < LED_KNOB_MIN_DELTA_P) return;
  brightnessPercent = mapped;
  applyLEDOutputs();
  updateLEDPageDynamic();
}

void setPresetWarm() { warmPercent = 100; coolPercent = 0; applyLEDOutputs(); updateLEDPageDynamic(true); }
void setPresetNeutral() { warmPercent = 50; coolPercent = 50; applyLEDOutputs(); updateLEDPageDynamic(true); }
void setPresetCool() { warmPercent = 0; coolPercent = 100; applyLEDOutputs(); updateLEDPageDynamic(true); }

void makeWarmer() {
  if (warmPercent >= 100) return;
  warmPercent = min(100, warmPercent + TINT_STEP_PERCENT);
  coolPercent = 100 - warmPercent;
  applyLEDOutputs();
  updateLEDPageDynamic(true);
}

void makeCooler() {
  if (coolPercent >= 100) return;
  coolPercent = min(100, coolPercent + TINT_STEP_PERCENT);
  warmPercent = 100 - coolPercent;
  applyLEDOutputs();
  updateLEDPageDynamic(true);
}

// ======================================================
// TOUCH HANDLING
// ======================================================
void handleTouch() {
  int16_t x, y;
  if (!getTouchPoint(x, y)) return;

  // Tabs
  if (pointInButton(tabTemp, x, y)) {
    currentPage = PAGE_TEMP;
    adjustingSetTemp = false;
    clearSetLabel();
    drawTempPageStatic();
    delay(200);
    return;
  }
  if (pointInButton(tabLED, x, y)) {
    currentPage = PAGE_LED;
    adjustingSetTemp = false;
    clearSetLabel();
    drawLEDPageStatic();
    updateLEDPageDynamic(true);
    delay(200);
    return;
  }

  if (currentPage == PAGE_TEMP) {
    if (pointInButton(btnSetTarget, x, y)) {
      captureTargetFromPreview();
      delay(200);
      return;
    }
    if (pointInButton(btnHeatToggle, x, y)) {
      heaterEnabled = !heaterEnabled;
      if (!heaterEnabled) {
        heaterWrite(false);
        heatMode = HM_STARTUP;
      } else {
        recomputePreheatCutTemp();
      }
      updateTempPageDynamic(true);
      delay(200);
      return;
    }
    if (pointInDialCenter(x, y)) {
      if (!adjustingSetTemp) {
        adjustingSetTemp = true;
        knobTempPreviewC = targetTempC;
        tempKnobEMA = -1.0f;
        drawSetLabel();
      }
      delay(200);
      return;
    }
  } else if (currentPage == PAGE_LED) {
    if (pointInButton(btnPresetWarm, x, y)) { setPresetWarm(); delay(200); return; }
    if (pointInButton(btnPresetNeutral, x, y)) { setPresetNeutral(); delay(200); return; }
    if (pointInButton(btnPresetCool, x, y)) { setPresetCool(); delay(200); return; }
    if (pointInButton(btnTintWarmer, x, y)) { makeWarmer(); delay(200); return; }
    if (pointInButton(btnTintCooler, x, y)) { makeCooler(); delay(200); return; }
  }
}

// ======================================================
// SETUP & LOOP
// ======================================================
void setup() {
  pinMode(PIN_WARM_LED, OUTPUT);
  pinMode(PIN_COOL_LED, OUTPUT);
  pinMode(PIN_FAN, OUTPUT);
  pinMode(PIN_RELAY_HEAT, OUTPUT);
  analogWrite(PIN_WARM_LED, 0);
  analogWrite(PIN_COOL_LED, 0);
  digitalWrite(PIN_FAN, LOW);
  heaterWrite(false);

  tft.begin();
  tft.setRotation(1);

  if (!ctp.begin(40)) {
    tft.fillScreen(COL_BG);
    tft.setTextColor(COL_ERROR, COL_BG);
    tft.setTextSize(2);
    tft.setCursor(20, 100);
    tft.print("TOUCH NOT FOUND");
    while (1) {}
  }
  if (!thermo.begin(MAX31865_3WIRE)) {
    tft.fillScreen(COL_BG);
    tft.setTextColor(COL_ERROR, COL_BG);
    tft.setTextSize(2);
    tft.setCursor(20, 120);
    tft.print("MAX31865 FAIL");
    while (1) {}
  }

  delay(300);
  readTemperature();
  if (targetTempC < TEMP_MIN_C) targetTempC = TEMP_MIN_C;
  if (targetTempC > MAX_TEMP_C) targetTempC = MAX_TEMP_C;
  knobTempPreviewC = targetTempC;
  recomputePreheatCutTemp();
  currentPage = PAGE_TEMP;
  drawTempPageStatic();
}

void loop() {
  handleTouch();
  if (millis() - lastTempReadMs >= TEMP_PERIOD_MS) {
    lastTempReadMs = millis();
    readTemperature();
    if (currentPage == PAGE_TEMP) {
      updateTempPageDynamic();
    }
    updateHeaterControl();
    updateHeaterStatusLabel();
  }
  if (currentPage == PAGE_TEMP && adjustingSetTemp) {
    updateTempPreviewFromKnob();
  }
  if (currentPage == PAGE_LED) {
    updateBrightnessFromKnob();
  }
  delay(10);
}