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.
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);
}