ESP32 Heater Controller


I’ll re-check the layout after your R4/J7 move, distinguish PCB airwires from actual schematic one-sided wiring, then evaluate whether BOOT/RESET buttons can be removed in favor of ESP32-S3 native USB auto-download behavior.
Done.
Accomplished:
  • Verified RT6/C25, RT4/C23, C14, and related passives are schematic-connected on both sides; the issue was PCB routing/airwires, not one-sided schematic wiring.
  • Removed optional S1 BOOT and S2 RESET buttons to save BOM/assembly cost.
  • Kept the important ESP32 support: R4 GPIO0 pull-up, R3 EN pull-up, and C11 EN reset-delay cap.
  • Cleaned stale routing from your R4/J7 move and re-routed.
  • Final checked categories are clean: no airwires, dangling traces, copper overlaps, invalid layers, or relevant ERC errors.
Open items:
  • If we want best serviceability, add tiny labeled test pads for GPIO0/BOOT and EN/RESET later; they are cheaper than buttons and useful for recovery.
Suggested next steps:
  1. Visually inspect the routed PCB.
  2. Add/verify GND pours and antenna keepout.
  3. Run full manufacturing DRC before export.
  4. Update firmware/bring-up docs to note USB flashing without BOOT/RESET buttons.
Pours, keepout, docs
Add test pads
Verify GND pours
Check antenna keepout
Update docs
Manufacturing review

J1
U3
U2
U5
Firmware Starter — ESP32 Heater Controller
Platform & Toolchain
  • MCU: ESP32-S3-WROOM-1-N4R2 (U1)
  • Framework: Arduino for ESP32 via PlatformIO
  • Primary purpose: safe first firmware for USB-C powered 4-channel heater controller
  • Safety default: heaters off at boot, reset, brownout, sensor fault, unknown input power, and watchdog reset.
Pin Mapping

Table


FunctionGPIO / Module PinNetConnected ToDirectionNotes
USB D-GPIO19 / IO19USB_D_NJ1 USB-C, U5 ESDUSBNative USB programming/data
USB D+GPIO20 / IO20USB_D_PJ1 USB-C, U5 ESDUSBNative USB programming/data
I2C SCLGPIO8 / IO8I2C_SCLU2 STUSB4500, J6 Qwiic, R7 4.7kOpen-drain3.3 V bus
I2C SDAGPIO9 / IO9I2C_SDAU2 STUSB4500, J6 Qwiic, R6 4.7kOpen-drain3.3 V bus
STUSB ALERTGPIO38 / IO38PD_ALERTU2 ALERT, R8 4.7k pull-upInputActive-low/open-drain alert
Heater 1 PWMGPIO10 / IO10HEATER1_PWMR20 100R -> Q1 gateOutputLow-side MOSFET driver
Heater 2 PWMGPIO11 / IO11HEATER2_PWMR21 100R -> Q2 gateOutputLow-side MOSFET driver
Heater 3 PWMGPIO12 / IO12HEATER3_PWMR22 100R -> Q3 gateOutputLow-side MOSFET driver
Heater 4 PWMGPIO13 / IO13HEATER4_PWMR23 100R -> Q4 gateOutputLow-side MOSFET driver
Heater safety NTC 1GPIO1 / IO1TEMP_HEATER1_ADCRT1 + R30 10k pull-upADC input10k NTC divider to GND
Heater safety NTC 2GPIO2 / IO2TEMP_HEATER2_ADCRT2 + R31 10k pull-upADC input10k NTC divider to GND
Heater safety NTC 3GPIO3 / IO3TEMP_HEATER3_ADCRT3 + R32 10k pull-upADC input10k NTC divider to GND
Heater safety NTC 4GPIO4 / IO4TEMP_HEATER4_ADCRT4 + R33 10k pull-upADC input10k NTC divider to GND
Ambient NTC AGPIO5 / IO5TEMP_AMBIENT_A_ADCRT5 + R34 10k pull-upADC input10k NTC divider to GND
Ambient NTC BGPIO6 / IO6TEMP_AMBIENT_B_ADCRT6 + R35 10k pull-upADC input10k NTC divider to GND
Encoder AGPIO17 / IO17ENC_ASW1 A, R50 10k pull-upInputDebounced/interrupt-capable
Encoder BGPIO18 / IO18ENC_BSW1 B, R51 10k pull-upInputDebounced/interrupt-capable
Encoder pushGPIO21 / IO21ENC_BUTTONSW1 switch, R52 10k pull-upInputActive low
RGB redGPIO14 / IO14RGB_RED_DRVR40 560R -> D2 cathodeOutputActive low, LED anode to 3V3
RGB greenGPIO15 / IO15RGB_GREEN_DRVR41 220R -> D3 cathodeOutputActive low, LED anode to 3V3
RGB blueGPIO16 / IO16RGB_BLUE_DRVR42 220R -> D4 cathodeOutputActive low, LED anode to 3V3
BOOTGPIO0 / IO0ESP_BOOTS1, R4 10k pull-upInputHold low during reset for bootloader
EN / resetENESP_ENS2, R3 10k pull-up, C11InputReset button
UART TX fallbackTXD0UART_TXD0J7 pin 3Output3.3 V UART
UART RX fallbackRXD0UART_RXD0J7 pin 4Input3.3 V UART
Dependencies & Project Setup
Create a PlatformIO project with this platformio.ini:

Ini


[env:esp32-s3-heater-controller]
platform = espressif32@6.6.0
board = esp32-s3-devkitc-1
framework = arduino
monitor_speed = 115200
upload_speed = 921600
board_build.mcu = esp32s3
board_build.f_cpu = 240000000L
build_flags =
  -DARDUINO_USB_MODE=1
  -DARDUINO_USB_CDC_ON_BOOT=1
lib_deps =
  adafruit/Adafruit SSD1306@^2.5.10
  adafruit/Adafruit GFX Library@^1.11.9
Complete Firmware Source
Save as src/main.cpp.

Cpp


#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <esp_task_wdt.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <math.h>

// -----------------------------------------------------------------------------
// Pin definitions from schematic
// -----------------------------------------------------------------------------
constexpr int PIN_I2C_SCL = 8;
constexpr int PIN_I2C_SDA = 9;
constexpr int PIN_PD_ALERT = 38;

constexpr int PIN_HEATER_PWM[4] = {10, 11, 12, 13};
constexpr int PIN_TEMP_HEATER[4] = {1, 2, 3, 4};
constexpr int PIN_TEMP_AMBIENT[2] = {5, 6};

constexpr int PIN_ENC_A = 17;
constexpr int PIN_ENC_B = 18;
constexpr int PIN_ENC_BUTTON = 21;

constexpr int PIN_LED_RED = 14;    // active low
constexpr int PIN_LED_GREEN = 15;  // active low
constexpr int PIN_LED_BLUE = 16;   // active low

// -----------------------------------------------------------------------------
// Electrical constants
// -----------------------------------------------------------------------------
constexpr float ADC_REF_MV = 3300.0f;
constexpr float NTC_PULLUP_OHMS = 10000.0f;
constexpr float NTC_R25_OHMS = 10000.0f;
constexpr float NTC_BETA = 3950.0f;       // Verify against final populated NTC MPN.
constexpr float TEMP_K_25C = 298.15f;

constexpr float HEATER_TARGET_MAX_C = 38.0f;
constexpr float HEATER_SAFETY_SHUTDOWN_C = 45.0f;  // Conservative first firmware threshold; tune after thermal tests.
constexpr float SENSOR_OPEN_C = -40.0f;
constexpr float SENSOR_SHORT_C = 125.0f;

constexpr uint8_t STUSB4500_ADDR = 0x28;   // ADDR0/ADDR1 grounded in schematic.
constexpr uint8_t OLED_ADDR = 0x3C;        // Common Qwiic OLED default; change if final OLED differs.
constexpr int OLED_W = 128;
constexpr int OLED_H = 64;

constexpr int PWM_FREQ_HZ = 1000;
constexpr int PWM_RES_BITS = 10;
constexpr int PWM_MAX = (1 << PWM_RES_BITS) - 1;

// WiFi placeholders; firmware must remain safe if WiFi is absent.
const char *WIFI_SSID = "YOUR_SSID";
const char *WIFI_PASSWORD = "YOUR_PASSWORD";

Adafruit_SSD1306 display(OLED_W, OLED_H, &Wire, -1);

volatile int32_t encoderTicks = 0;
volatile uint8_t lastEncoderState = 0;

float heaterTempsC[4] = {NAN, NAN, NAN, NAN};
float ambientTempsC[2] = {NAN, NAN};
bool heatersAllowed = false;
uint16_t heaterDuty[4] = {0, 0, 0, 0};
uint32_t lastSensorMs = 0;
uint32_t lastDisplayMs = 0;
uint32_t lastWifiMs = 0;

void setLed(bool red, bool green, bool blue) {
  digitalWrite(PIN_LED_RED, red ? LOW : HIGH);
  digitalWrite(PIN_LED_GREEN, green ? LOW : HIGH);
  digitalWrite(PIN_LED_BLUE, blue ? LOW : HIGH);
}

void setAllHeatersOff() {
  for (int i = 0; i < 4; i++) {
    heaterDuty[i] = 0;
    ledcWrite(i, 0);
  }
}

void setHeaterDuty(int channel, uint16_t duty10bit) {
  if (channel < 0 || channel >= 4) return;
  if (!heatersAllowed) duty10bit = 0;
  if (duty10bit > PWM_MAX) duty10bit = PWM_MAX;
  heaterDuty[channel] = duty10bit;
  ledcWrite(channel, duty10bit);
}

float readNtcC(int pin) {
  // Use millivolts when supported by ESP32 Arduino for calibrated ADC behavior.
  uint32_t mv = analogReadMilliVolts(pin);
  if (mv < 50) return SENSOR_SHORT_C;          // NTC nearly shorted to GND / very hot equivalent.
  if (mv > 3250) return SENSOR_OPEN_C;         // Open NTC or missing ground path.

  float v = static_cast<float>(mv);
  float rNtc = NTC_PULLUP_OHMS * v / (ADC_REF_MV - v);
  float invT = (1.0f / TEMP_K_25C) + (logf(rNtc / NTC_R25_OHMS) / NTC_BETA);
  float tempK = 1.0f / invT;
  return tempK - 273.15f;
}

bool sensorsHealthy() {
  for (float t : heaterTempsC) {
    if (!isfinite(t) || t <= SENSOR_OPEN_C + 0.1f || t >= SENSOR_SHORT_C - 0.1f) return false;
  }
  for (float t : ambientTempsC) {
    if (!isfinite(t) || t <= SENSOR_OPEN_C + 0.1f || t >= SENSOR_SHORT_C - 0.1f) return false;
  }
  return true;
}

bool heaterTempsSafe() {
  for (float t : heaterTempsC) {
    if (!isfinite(t) || t >= HEATER_SAFETY_SHUTDOWN_C) return false;
  }
  return true;
}

void updateSensors() {
  for (int i = 0; i < 4; i++) heaterTempsC[i] = readNtcC(PIN_TEMP_HEATER[i]);
  for (int i = 0; i < 2; i++) ambientTempsC[i] = readNtcC(PIN_TEMP_AMBIENT[i]);

  if (!sensorsHealthy() || !heaterTempsSafe()) {
    heatersAllowed = false;
    setAllHeatersOff();
    setLed(true, false, false);
  }
}

bool i2cDevicePresent(uint8_t addr) {
  Wire.beginTransmission(addr);
  return Wire.endTransmission() == 0;
}

void scanI2C() {
  Serial.println("I2C scan:");
  for (uint8_t a = 0x08; a < 0x78; a++) {
    if (i2cDevicePresent(a)) {
      Serial.printf("  found 0x%02X\n", a);
    }
  }
}

uint8_t readStusbReg(uint8_t reg) {
  Wire.beginTransmission(STUSB4500_ADDR);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return 0xFF;
  if (Wire.requestFrom(STUSB4500_ADDR, static_cast<uint8_t>(1)) != 1) return 0xFF;
  return Wire.read();
}

void checkPdController() {
  if (!i2cDevicePresent(STUSB4500_ADDR)) {
    Serial.println("STUSB4500 not found; assume unknown input capability, heaters disabled.");
    heatersAllowed = false;
    return;
  }
  uint8_t alertStatus = readStusbReg(0x0B); // Informational diagnostic read; verify register map in production firmware.
  Serial.printf("STUSB4500 present, status/reg 0x0B = 0x%02X\n", alertStatus);
  // IMPORTANT: Production firmware should verify the STUSB4500 NVM/PDOs are 5 V-only before enabling heaters.
}

void IRAM_ATTR handleEncoder() {
  uint8_t a = digitalRead(PIN_ENC_A);
  uint8_t b = digitalRead(PIN_ENC_B);
  uint8_t state = (a << 1) | b;
  uint8_t transition = (lastEncoderState << 2) | state;
  if (transition == 0b0001 || transition == 0b0111 || transition == 0b1110 || transition == 0b1000) encoderTicks++;
  if (transition == 0b0010 || transition == 0b0100 || transition == 0b1101 || transition == 0b1011) encoderTicks--;
  lastEncoderState = state;
}

void initGpio() {
  pinMode(PIN_PD_ALERT, INPUT_PULLUP);

  pinMode(PIN_LED_RED, OUTPUT);
  pinMode(PIN_LED_GREEN, OUTPUT);
  pinMode(PIN_LED_BLUE, OUTPUT);
  setLed(false, false, true);

  pinMode(PIN_ENC_A, INPUT_PULLUP);
  pinMode(PIN_ENC_B, INPUT_PULLUP);
  pinMode(PIN_ENC_BUTTON, INPUT_PULLUP);
  lastEncoderState = (digitalRead(PIN_ENC_A) << 1) | digitalRead(PIN_ENC_B);
  attachInterrupt(digitalPinToInterrupt(PIN_ENC_A), handleEncoder, CHANGE);
  attachInterrupt(digitalPinToInterrupt(PIN_ENC_B), handleEncoder, CHANGE);

  for (int i = 0; i < 4; i++) {
    pinMode(PIN_HEATER_PWM[i], OUTPUT);
    digitalWrite(PIN_HEATER_PWM[i], LOW);
    ledcSetup(i, PWM_FREQ_HZ, PWM_RES_BITS);
    ledcAttachPin(PIN_HEATER_PWM[i], i);
    ledcWrite(i, 0);
  }

  for (int pin : PIN_TEMP_HEATER) analogSetPinAttenuation(pin, ADC_11db);
  for (int pin : PIN_TEMP_AMBIENT) analogSetPinAttenuation(pin, ADC_11db);
}

void initDisplay() {
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    Serial.println("OLED not found at 0x3C; continuing headless.");
    return;
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("ESP32 Heater Ctrl");
  display.println("Boot: heaters OFF");
  display.display();
}

void updateDisplay() {
  if (!i2cDevicePresent(OLED_ADDR)) return;
  display.clearDisplay();
  display.setCursor(0, 0);
  display.printf("Allowed: %s\n", heatersAllowed ? "YES" : "NO");
  for (int i = 0; i < 4; i++) display.printf("H%d: %.1fC D:%u\n", i + 1, heaterTempsC[i], heaterDuty[i]);
  display.printf("A: %.1f %.1fC\n", ambientTempsC[0], ambientTempsC[1]);
  display.printf("Enc: %ld Btn:%d\n", static_cast<long>(encoderTicks), digitalRead(PIN_ENC_BUTTON) == LOW);
  display.display();
}

void connectWiFiNonBlocking() {
  if (strcmp(WIFI_SSID, "YOUR_SSID") == 0) return;
  if (WiFi.status() == WL_CONNECTED) return;
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}

void printStatus() {
  Serial.printf("H=[%.1f %.1f %.1f %.1f] A=[%.1f %.1f] allowed=%d enc=%ld button=%d\n",
                heaterTempsC[0], heaterTempsC[1], heaterTempsC[2], heaterTempsC[3],
                ambientTempsC[0], ambientTempsC[1], heatersAllowed,
                static_cast<long>(encoderTicks), digitalRead(PIN_ENC_BUTTON) == LOW);
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("ESP32 Heater Controller safe firmware starting");

  initGpio();
  setAllHeatersOff();
  heatersAllowed = false;

  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, 400000);
  scanI2C();
  checkPdController();
  initDisplay();

  esp_task_wdt_init(8, true);
  esp_task_wdt_add(NULL);

  updateSensors();
  Serial.println("Init complete. Heaters remain disabled until firmware policy enables them.");
}

void loop() {
  esp_task_wdt_reset();
  uint32_t now = millis();

  if (now - lastSensorMs >= 500) {
    lastSensorMs = now;
    updateSensors();
    printStatus();
  }

  if (now - lastDisplayMs >= 1000) {
    lastDisplayMs = now;
    updateDisplay();
  }

  if (now - lastWifiMs >= 10000) {
    lastWifiMs = now;
    connectWiFiNonBlocking();
  }

  // First firmware policy: only allow manual enable by holding encoder button, and only if sensors are healthy.
  // Production firmware should replace this with power-source classification, PID/control logic, and latched fault handling.
  if (digitalRead(PIN_ENC_BUTTON) == LOW && sensorsHealthy() && heaterTempsSafe()) {
    heatersAllowed = true;
    setLed(false, true, false);
  }

  if (!heatersAllowed) {
    setAllHeatersOff();
  }

  delay(10);
}
Build & Flash Instructions

Bash


pio run
pio run -t upload
pio device monitor -b 115200
Bootloader entry: hold S1 / ESP_BOOT / GPIO0 low, press and release S2 / ESP_EN, then release S1. Native USB uses USB_D_P/USB_D_N through the USB-C connector.
Firmware Safety Requirements for Production
  • Verify STUSB4500 NVM/PDO configuration is 5 V-only before enabling heaters or connecting to PD laptop chargers.
  • Start every boot with all heater PWM pins low.
  • Keep heaters disabled until input power capability is known.
  • Latch off on NTC open/short, ADC out-of-range, overtemperature, watchdog reset, brownout reset, or repeated eFuse cycling.
  • Add a maximum on-time / refresh watchdog for heater outputs so a firmware hang cannot leave heat enabled.
  • Treat PCB NTCs as control/supplementary safety sensors, not the sole independent protection for off-board heaters.
  • Platform & Toolchain

  • Pin Mapping

  • Dependencies & Project Setup

  • Complete Firmware Source

  • Build & Flash Instructions

  • Firmware Safety Requirements for Production