ESP32 Heater Controller


Okay. I had to move R4 and J7. Sorry if that messes anything up.
Also. There are still issues. RT6 is only connected on one side. C23. RT4 C25. C14. Countless others are only wired up on one size.
Also, ESP32 devkits have a capacitor? Or resistor? That is wired in so you can flash firmware without the S1 and S2 buttons. And I'm not sure if the buttons are necessary, so I'm happy to save money if we can safely nix them.
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

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