Complete beginner needs help: Dual-Core ESP32 + HX711 scale drifts wildly after 10 mins of perfect work. Is my 0.6A PSU overheating?

Hi everyone,

I am a beginner in industrial electronics, so please go easy on me. I’ve built an automated liquid filling machine. It works perfectly for a short time, but then completely loses its accuracy. I suspect a hardware/power/thermal issue and really need your advice before I tear the whole panel apart.

The Hardware Setup:

  • MCU: Dual-Core ESP32.
  • ADC: HX711 (set to 80Hz mode) reading a load cell.
  • Pump: 220V AC high-flow pump controlled by a VFD. It fills 5 Liters in about 15 seconds (~333g/sec).
  • Power Supply: A cheap 5V / 0.6A Switch-Mode Power Supply (SMPS). It powers the ESP32, the optocouplers, the HX711, and 3 reed switches.

Outputs (Galvanically Isolated):

I have two types of outputs:

  1. Pump control: A simple optocoupler that closes the VFD contacts to turn the pump on/off.
  2. Pneumatic valves (220V AC): Controlled via triacs. The circuit is: ESP32 pin $\rightarrow$ 150 Ohm resistor $\rightarrow$ MOC3052 opto-triac $\rightarrow$ BT131 triac $\rightarrow$ output terminal.

HX711 Protection & Shielding (What I've already done):

To protect the ADC from the VFD noise, I moved the HX711 to a separate small top box on its own perfboard.

  • I added a 470uF capacitor specifically on the HX711 power input.
  • I carefully wrapped the contacts with electrical tape.
  • I built a Faraday cage by hot-gluing an aluminum foil shield around the HX711 board.
  • I grounded this foil shield, AND the shield of the cable running from the ESP32 to the HX711, directly to the SMPS Ground.

The Physical Layout (The "Spaghetti" Panel):

Everything else is crammed into one plastic enclosure. My ESP32 perfboard is mounted just a few inches away from the VFD. The 220V AC wires and the low-voltage wires are somewhat separated but not perfectly isolated in dedicated cable ducts. Currently, there are NO bypass capacitors on the 5V line right at the ESP32 itself.

The Symptoms:

When the machine is "cold" (e.g., after sitting off for a day), it works flawlessly. It perfectly fills 20 consecutive 5-Liter bottles with an amazing accuracy of $\pm 5$ to $8$ grams.

However, right after the 20th bottle (about 10 minutes of continuous work), the weight readings start jumping randomly. The scatter goes up to $\pm 30$g or worse. If I let it cool down, it works perfectly again.

My Hypotheses & Questions for the pros:

  1. Power Supply Overheating? Since my SMPS is only 0.6A, and the ESP32 (running a WiFi AP) + optocouplers draw near 400-500mA peaks, is the SMPS overheating after 10 mins and sending heavy voltage ripples to the ESP32, causing it to drop ADC ticks?
  2. Capacitors on ESP32: I have 470uF on the HX711, but should I absolutely add a 1000uF electrolytic + a 0.1uF ceramic capacitor directly to the 5V/GND pins of the ESP32 to buffer the WiFi power spikes?
  3. Software Issue? I'm using FreeRTOS to separate WiFi/Web (Core 1) from the HX711 reading (Core 0). Could my code be causing this drift over time?

Here is my code:

#include <Arduino.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h> 
#include <Preferences.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <atomic>

Preferences preferences; 

#define PIN_IN_TRAY      16  
#define PIN_IN_HEAD_UP   17  
#define PIN_IN_HEAD_DOWN 21  

#define HX_COMMON_SCK  18 
#define HX_DT_PLATFORM 23 

#define PIN_PUMP_MAIN   4   
#define PIN_PUMP_AUX    5   
#define PIN_NOZZLE      13  
#define PIN_WATER       14  
#define PIN_AIR         27  
#define PIN_PRODUCT     32  
#define PIN_HEAD        25  
#define PIN_DRIP        33  
#define PIN_SPARE_1     26  
#define PIN_SPARE_2     15  

#define EMERGENCY_VALVE_SHUTOFF() { \
  GPIO.out_w1tc = (1 << PIN_PUMP_MAIN) | (1 << PIN_NOZZLE) | (1 << PIN_HEAD); \
  GPIO.out1_w1tc.val = (1 << (PIN_PRODUCT - 32)); \
}

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

std::atomic<float> atomicTargetWeight{5000.0f};   
std::atomic<float> atomicWeightOffset{0.0f};  
std::atomic<float> weightRealDisplay{0.0f};   
std::atomic<int>   currentStateAtomic{0};     
std::atomic<bool>  triggerFillStop{false};    
std::atomic<bool>  requestSingleTare{false};  

volatile float expectedTare = 100.0f;     
volatile float tareTolerance = 30.0f;    
volatile float calibrationFactor = 107.16f; 
int   washSec = 5;  
int   blowSec = 3;  
float pauseSec = 2.0f;
int   totalBottles = 0;

volatile long tareBase = 0;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;

bool isAutoArmed = false;       
bool isBottlePresent = false;   
unsigned long stableTimer = 0;  
unsigned long stateTimer = 0;

char machineStatus[32] = "IDLE";
bool isAutoMode = false;        

enum MachineState {
  STATE_IDLE = 0,          
  STATE_TRAY_MOVING,   
  STATE_HEAD_MOVING,   
  STATE_FILLING,       
  STATE_HEAD_LIFTING,  
  STATE_TRAY_RETURN,   
  STATE_EMERGENCY_LIFT, 
  STATE_WASH_PREPARE,   
  STATE_WASHING,        
  STATE_WASH_FINISH,
  STATE_BLOW_PREPARE,
  STATE_BLOWING_PROCESS,
  STATE_BLOW_FINISH,
  STATE_WAIT_REMOVAL
};

MachineState currentState = STATE_IDLE;

bool isSensorActive(int pin) { return digitalRead(pin) == HIGH; }

uint8_t targetMac[] = {0x68, 0xFE, 0x71, 0x0C, 0x7E, 0x50}; 

void checkSecurity() {
  uint8_t chipMac[6];
  WiFi.macAddress(chipMac);
  if (memcmp(chipMac, targetMac, sizeof(chipMac)) != 0) {
    while (true) { digitalWrite(PIN_PUMP_MAIN, LOW); delay(100); }
  }
}

void changeState(MachineState newState) {
  currentState = newState;
  currentStateAtomic.store((int)newState, std::memory_order_relaxed);
  stateTimer = millis();
}

void stopAll() {
  EMERGENCY_VALVE_SHUTOFF(); 
  digitalWrite(PIN_WATER, LOW);
  digitalWrite(PIN_AIR, LOW);
  digitalWrite(PIN_PUMP_AUX, LOW);
  digitalWrite(PIN_DRIP, HIGH); 

  strlcpy(machineStatus, "STOP / RETURN", sizeof(machineStatus));
  isAutoMode = false;
  isAutoArmed = false; 
  isBottlePresent = false; 
  stableTimer = 0;         
  atomicWeightOffset.store(0.0f, std::memory_order_relaxed); 
  changeState(STATE_EMERGENCY_LIFT);
}

void startCycle() {
  isAutoMode = true;
  atomicWeightOffset.store(weightRealDisplay.load(std::memory_order_relaxed), std::memory_order_relaxed);
  
  strlcpy(machineStatus, "START: TRAY", sizeof(machineStatus));
  digitalWrite(PIN_DRIP, HIGH); 
  changeState(STATE_TRAY_MOVING);
}

void checkStartCondition() {
  if (!isSensorActive(PIN_IN_HEAD_UP)) {
    strlcpy(machineStatus, "ERR: HEAD NOT UP", sizeof(machineStatus));
    isAutoArmed = false; 
    return;
  }
  if (isSensorActive(PIN_IN_TRAY)) { 
    strlcpy(machineStatus, "ERR: TRAY IS IN", sizeof(machineStatus));
    isAutoArmed = false;
    return;
  }
  startCycle();
}

void startWash() {
  strlcpy(machineStatus, "WASH: START", sizeof(machineStatus));
  digitalWrite(PIN_DRIP, HIGH); 
  changeState(STATE_WASH_PREPARE);
}

void startBlow() {
  strlcpy(machineStatus, "BLOW: START", sizeof(machineStatus));
  digitalWrite(PIN_DRIP, HIGH); 
  changeState(STATE_BLOW_PREPARE);
}

void togglePin(int pin) {
  if (isAutoMode) return; 
  digitalWrite(pin, !digitalRead(pin));
}

void weightTaskCore(void * pvParameters) {
  float localSmoothedWeight = 0.0f;

  for(;;) {
    unsigned long waitTime = micros();
    bool timeout = false;
    
    while(digitalRead(HX_DT_PLATFORM) == HIGH) {
      if (micros() - waitTime > 50000) { timeout = true; break; }
    }
    if (timeout) {
      vTaskDelay(pdMS_TO_TICKS(2)); 
      continue; 
    }

    long count = 0;
    portENTER_CRITICAL(&mux);
    for (int i = 0; i < 24; i++) {
      GPIO.out_w1ts = (1 << HX_COMMON_SCK); delayMicroseconds(1); 
      count = count << 1;
      if (GPIO.in & (1 << HX_DT_PLATFORM)) count++; 
      GPIO.out_w1tc = (1 << HX_COMMON_SCK); delayMicroseconds(1);
    }
    GPIO.out_w1ts = (1 << HX_COMMON_SCK); delayMicroseconds(1);
    GPIO.out_w1tc = (1 << HX_COMMON_SCK); 
    portEXIT_CRITICAL(&mux);
    count ^= 0x800000;

    vTaskDelay(pdMS_TO_TICKS(2));

    if (requestSingleTare.load(std::memory_order_relaxed)) {
        tareBase = count;       
        localSmoothedWeight = 0.0f; 
        requestSingleTare.store(false, std::memory_order_relaxed);
    }

    double currentRawDouble = (double)(count - tareBase) / (double)calibrationFactor;

    int currentStateCore1 = currentStateAtomic.load(std::memory_order_relaxed);

    if (currentStateCore1 == STATE_HEAD_MOVING) {
       continue; 
    }

    float alpha = 0.05f; 

    if (localSmoothedWeight == 0.0f || (currentStateCore1 == STATE_IDLE && abs(currentRawDouble - localSmoothedWeight) > 100.0)) {
        localSmoothedWeight = (float)currentRawDouble; 
    } else {
        localSmoothedWeight = (alpha * currentRawDouble) + ((1.0f - alpha) * localSmoothedWeight);
    }
    
    weightRealDisplay.store(localSmoothedWeight, std::memory_order_relaxed); 

    if (currentStateCore1 == STATE_FILLING) { 
      float target = atomicTargetWeight.load(std::memory_order_relaxed);
      float offset = atomicWeightOffset.load(std::memory_order_relaxed);
      
      float liquidWeight = localSmoothedWeight - offset;
      
      if (liquidWeight >= target) {
        EMERGENCY_VALVE_SHUTOFF(); 
        currentStateAtomic.store(STATE_HEAD_LIFTING, std::memory_order_relaxed);
        triggerFillStop.store(true, std::memory_order_relaxed); 
      }
    }
  }
}

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML><html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
  <title>REAKTOR HMI</title>
  <style>
    * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
    body { background-color: #121212; color: white; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 0; width: 100vh; height: 100vw; transform: rotate(90deg); transform-origin: top left; position: absolute; top: 0; left: 100%; overflow: hidden; user-select: none; }
    .container { display: grid; grid-template-columns: 1fr 1fr; gap: 1vh; height: 100%; padding: 1vh 1vw; }
    .col { display: flex; flex-direction: column; gap: 1vh; height: 100%; }
    .box { background: #1e1e2e; border-radius: 10px; padding: 1vh; border: 1px solid #444; }
    .box-green { border-color: #2e7d32; background: #1b3a1b; }
    .box-red { border-color: #c62828; background: #2b0b0b; margin-top: 2vh; }
    .row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5vh; }
    .label { font-size: 2.2vh; font-weight: bold; color: #ddd; }
    input[type="number"] { background: #333; border: 1px solid #555; color: #fff; font-size: 3vh; height: 5vh; width: 15vw; text-align: center; border-radius: 5px; padding: 0; outline: none;}
    .big-val { font-size: 6vh; font-weight: bold; color: #fff; background: #303050; padding: 0 15px; border-radius: 5px; min-width: 120px; text-align: right; line-height: 1.2;}
    .btn { border: none; border-radius: 5px; cursor: pointer; color: white; font-weight: bold; text-transform: uppercase; padding: 0; text-align: center; display: flex; align-items: center; justify-content: center;}
    .btn:active { transform: scale(0.98); opacity: 0.8; }
    .btn-sq { width: 6vh; height: 6vh; font-size: 2vh; background: #444; border: 2px solid #888; }
    .btn-sq.active { background: #00e676; border-color: #fff; color:black;}
    .btn-big { width: 100%; height: 12vh; font-size: 4vh; margin-top: 1vh; background: #2e7d32; }
    .btn-stop { width: 100%; height: 12vh; font-size: 4vh; background: #d32f2f; border: 2px solid #ff5252; }
    .led { width: 3vh; height: 3vh; border-radius: 50%; background: #333; border: 2px solid #555; display:inline-block;}
    .led.on { background: #00bcd4; border-color: #fff; box-shadow: 0 0 10px #00bcd4; }
    .grid-manual { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1vh; }
    .man-btn { width: 100%; height: 7vh; background: #555; border: 1px solid #777; border-radius: 5px; font-size: 1.8vh; color:white; }
    .man-btn.active { background: #ff9800; color: black; border-color: white; }
  </style>
</head>
<body>
<div class="container">
  <div class="col">
    <div class="box">
      <div class="row">
        <div class="big-val" id="val_weight">0</div>
        <div style="text-align:right">
          <div style="font-size:2vh; color:#aaa">РАЗОМ</div>
          <div style="font-size:4vh; font-weight:bold" id="val_total">0</div>
        </div>
      </div>
      <div class="row">
        <div class="label">ЗАВДАННЯ</div>
        <input type="number" id="inp_target" value="5000" onchange="sendCfg()">
        <button class="btn btn-sq" onclick="send('RST_CNT')">000</button>
      </div>
    </div>
    <div class="box">
      <div class="row">
        <div class="label">ТАРА (ГР)</div>
        <input type="number" id="inp_tare_val" value="100" onchange="sendCfg()">
        <button class="btn btn-sq" onclick="send('TARE')">TARA</button>
      </div>
      <div class="row">
        <div class="label">ПАУЗА (СЕК)</div>
        <input type="number" id="inp_pause" value="2" step="0.1" onchange="sendCfg()">
      </div>
      <div class="row">
        <div class="label">ПРОДУВ (СЕК)</div>
        <input type="number" id="inp_blow" value="3" onchange="sendCfg()">
      </div>
      <div class="row">
        <div class="label">ПРОМИВ (СЕК)</div>
        <input type="number" id="inp_wash" value="5" onchange="sendCfg()">
      </div>
      <div class="row">
        <div class="label">КОЕФ.</div>
        <input type="number" id="inp_coef" value="107.16" onchange="sendCfg()">
      </div>
    </div>
    <div class="box">
       <div style="font-size:2vh; color:#888;">СТАТУС:</div>
       <div style="font-size:3vh; font-weight:bold; color:#00e676" id="status_text">---</div>
       <div class="row" style="margin-top:10px">
          <div><span class="led" id="led_up"></span> ВЕРХ</div>
          <div><span class="led" id="led_down"></span> НИЗ</div>
          <div><span class="led" id="led_tray"></span> ЛОТОК</div>
       </div>
    </div>
  </div> 
  <div class="col">
    <div class="box box-green">
      <div class="label" style="text-align:center">АВТОМАТ</div>
      <button class="btn btn-big" id="btn_auto" onclick="send('START')">СТАРТ ЦИКЛУ</button>
      <div class="row" style="margin-top:10px">
        <button class="btn" style="background:#0288d1; flex:1; margin-right:5px; height:8vh; font-size:2.5vh" onclick="send('BLOW_START')">ПРОДУВКА</button>
        <button class="btn" style="background:#7b1fa2; flex:1; height:8vh; font-size:2.5vh" onclick="send('WASH_START')">ПРОМИВКА</button>
      </div>
    </div>
    <div class="box">
      <div class="label" style="text-align:center">РУЧНЕ КЕРУВАННЯ</div>
      <div class="grid-manual">
        <button class="man-btn" id="m_head" onclick="send('M_HEAD')">ГОЛОВА</button>
        <button class="man-btn" id="m_tray" onclick="send('M_TRAY')">ЛОТОК</button>
        <button class="man-btn" id="m_air" onclick="send('M_AIR')">ПОВІТРЯ</button>
        <button class="man-btn" id="m_water" onclick="send('M_WATER')">ВОДА</button>
        <button class="man-btn" id="m_prod" onclick="send('M_PROD')">СИРОВ</button>
        <button class="man-btn" id="m_noz" onclick="send('M_NOZ')">СОПЛО</button>
        <button class="man-btn" id="m_pump" onclick="send('M_PUMP')">НАСОС</button>
        <button class="man-btn" style="background:#555" onclick="send('REBOOT')">REBOOT</button>
      </div>
    </div>
    <div class="box box-red">
      <button class="btn btn-stop" onclick="send('STOP')">СТОП / АВАРІЯ</button>
    </div>
  </div>
</div>
<script>
  var ws = new WebSocket(`ws://${location.hostname}/ws`);
  ws.onmessage = function(e) {
    var d = JSON.parse(e.data);
    document.getElementById('val_weight').innerText = d.w;
    document.getElementById('val_total').innerText = d.cnt;
    document.getElementById('status_text').innerText = d.st;
    setLed('led_up', d.s[0]);
    setLed('led_tray', d.s[1]);
    setLed('led_down', d.s[2]);
    setMan('m_tray', d.o[0]);
    setMan('m_head', d.o[1]);
    setMan('m_noz', d.o[2]);
    setMan('m_pump', d.o[3]);
    setMan('m_prod', d.o[4]);
    setMan('m_air', d.o[5]);
    setMan('m_water', d.o[6]);
    if(d.auto) document.getElementById('btn_auto').style.background = "#00e676";
    else document.getElementById('btn_auto').style.background = "#2e7d32";
    
    if(d.cfg && !window.cfgLoaded) {
       document.getElementById('inp_target').value = d.cfg.t;
       document.getElementById('inp_tare_val').value = d.cfg.tar;
       document.getElementById('inp_pause').value = d.cfg.p;
       document.getElementById('inp_blow').value = d.cfg.b;
       document.getElementById('inp_wash').value = d.cfg.w;
       document.getElementById('inp_coef').value = d.cfg.k;
       window.cfgLoaded = true;
    }
  };
  function setLed(id, s) { var el = document.getElementById(id); if(s == '1') el.classList.add('on'); else el.classList.remove('on'); }
  function setMan(id, s) { var el = document.getElementById(id); if(s == '1') el.classList.add('active'); else el.classList.remove('active'); }
  function send(cmd) { ws.send(JSON.stringify({c: cmd})); }
  function sendCfg() {
  var t = document.getElementById('inp_target').value;
  var tar = document.getElementById('inp_tare_val').value;
  var k = document.getElementById('inp_coef').value; 
  var p = document.getElementById('inp_pause').value;
  var b = document.getElementById('inp_blow').value;
  var w = document.getElementById('inp_wash').value;
  ws.send(JSON.stringify({ c: 'CFG', t: t, tar: tar, k: k, p: p, b: b, w: w }));
}
</script>
</body></html>
)rawliteral";

void handleWS(void *arg, uint8_t *data, size_t len) {
  AwsFrameInfo *info = (AwsFrameInfo*)arg;
  if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
    
    JsonDocument doc;
    DeserializationError error = deserializeJson(doc, data, len);
    if (error) return;

    const char* cmd = doc["c"];

    if (strcmp(cmd, "START") == 0) {
      isAutoArmed = true; 
      strlcpy(machineStatus, "ARMED: WAIT TARE", sizeof(machineStatus));
    }
    else if (strcmp(cmd, "STOP") == 0) stopAll();
    else if (strcmp(cmd, "TARE") == 0) { requestSingleTare.store(true, std::memory_order_relaxed); }
    else if (strcmp(cmd, "RST_CNT") == 0) totalBottles = 0;
    else if (strcmp(cmd, "REBOOT") == 0) ESP.restart();

    else if (strcmp(cmd, "WASH_START") == 0) { startWash(); }
    else if (strcmp(cmd, "BLOW_START") == 0) { startBlow(); }

    else if (strcmp(cmd, "M_HEAD") == 0) {
      if (digitalRead(PIN_HEAD) == HIGH) { digitalWrite(PIN_HEAD, LOW); } 
      else {
        if (isSensorActive(PIN_IN_TRAY)) { digitalWrite(PIN_HEAD, HIGH); } 
        else { strlcpy(machineStatus, "ERR: TRAY IS OUT!", sizeof(machineStatus)); }
      }
    }
    else if (strcmp(cmd, "M_TRAY") == 0) {
      if (digitalRead(PIN_DRIP) == HIGH) {
        if (isSensorActive(PIN_IN_HEAD_UP)) { digitalWrite(PIN_DRIP, LOW); } 
        else { strlcpy(machineStatus, "ERR: HEAD IS DOWN!", sizeof(machineStatus)); }
      } else { digitalWrite(PIN_DRIP, HIGH); }
    }
    else if (strcmp(cmd, "M_NOZ") == 0) togglePin(PIN_NOZZLE);
    else if (strcmp(cmd, "M_PUMP") == 0) togglePin(PIN_PUMP_MAIN);
    else if (strcmp(cmd, "M_PROD") == 0) togglePin(PIN_PRODUCT);
    else if (strcmp(cmd, "M_AIR") == 0) togglePin(PIN_AIR);
    else if (strcmp(cmd, "M_WATER") == 0) togglePin(PIN_WATER);

    else if (strcmp(cmd, "CFG") == 0) {
      if(doc.containsKey("t")) { atomicTargetWeight.store(doc["t"].as<float>(), std::memory_order_relaxed); preferences.putFloat("tW", doc["t"].as<float>()); }
      if(doc.containsKey("tar")) { expectedTare = doc["tar"].as<float>(); preferences.putFloat("tar", expectedTare); }
      if(doc.containsKey("p")) { pauseSec = doc["p"].as<float>(); preferences.putFloat("p", pauseSec); }
      if(doc.containsKey("b")) { blowSec = doc["b"].as<int>(); preferences.putInt("b", blowSec); }
      if(doc.containsKey("w")) { washSec = doc["w"].as<int>(); preferences.putInt("w", washSec); }
      if(doc.containsKey("k")) {
        calibrationFactor = doc["k"].as<float>();
        preferences.putFloat("calFactor", calibrationFactor);
      }
    }
  }
}

void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
  if (type == WS_EVT_DATA) handleWS(arg, data, len);
}

void setup() {
  Serial.begin(115200);

  preferences.begin("reaktor", false);
  calibrationFactor = preferences.getFloat("calFactor", 107.16);
  atomicTargetWeight.store(preferences.getFloat("tW", 5000.0), std::memory_order_relaxed);
  expectedTare      = preferences.getFloat("tar", 100.0);
  pauseSec          = preferences.getFloat("p", 2.0);
  blowSec           = preferences.getInt("b", 3);
  washSec           = preferences.getInt("w", 5);
  
  checkSecurity();
  
  pinMode(PIN_PUMP_MAIN, OUTPUT); digitalWrite(PIN_PUMP_MAIN, LOW);
  pinMode(PIN_PUMP_AUX, OUTPUT); digitalWrite(PIN_PUMP_AUX, LOW);
  pinMode(PIN_NOZZLE, OUTPUT); digitalWrite(PIN_NOZZLE, LOW);
  pinMode(PIN_WATER, OUTPUT); digitalWrite(PIN_WATER, LOW);
  pinMode(PIN_PRODUCT, OUTPUT); digitalWrite(PIN_PRODUCT, LOW);
  pinMode(PIN_AIR, OUTPUT); digitalWrite(PIN_AIR, LOW);
  pinMode(PIN_HEAD, OUTPUT); digitalWrite(PIN_HEAD, LOW);
  pinMode(PIN_DRIP, OUTPUT); digitalWrite(PIN_DRIP, LOW); 

  pinMode(PIN_IN_TRAY, INPUT_PULLDOWN);
  pinMode(PIN_IN_HEAD_UP, INPUT_PULLDOWN);
  pinMode(PIN_IN_HEAD_DOWN, INPUT_PULLDOWN);

  pinMode(HX_DT_PLATFORM, INPUT);
  pinMode(HX_COMMON_SCK, OUTPUT);
  digitalWrite(HX_COMMON_SCK, LOW);

  xTaskCreatePinnedToCore(
      weightTaskCore,   
      "WeightTask",     
      4096,             
      NULL,             
      configMAX_PRIORITIES - 1,               
      NULL,             
      0);               

  WiFi.mode(WIFI_MODE_AP);

  WiFi.softAP("REAKTOR_PANEL", "88888888");
  
  ws.onEvent(onEvent);
  server.addHandler(&ws);
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *req){ req->send_P(200, "text/html", index_html); });
  server.begin();
  
  requestSingleTare.store(true, std::memory_order_relaxed); 
}

void loop() {
  ws.cleanupClients();

  if (triggerFillStop.load(std::memory_order_relaxed)) {
      triggerFillStop.store(false, std::memory_order_relaxed);
      changeState(STATE_HEAD_LIFTING); 
      strlcpy(machineStatus, "HEAD UP...", sizeof(machineStatus));
  }

  switch (currentState) {
    case STATE_IDLE:
      if (isAutoArmed) {
          float displayReal = weightRealDisplay.load(std::memory_order_relaxed);
          if (abs(displayReal - expectedTare) <= tareTolerance) { 
              if (!isBottlePresent) {
                  stableTimer = millis();
                  isBottlePresent = true;
                  strlcpy(machineStatus, "TARE OK. PAUSE...", sizeof(machineStatus));
              } else {
                  if (millis() - stableTimer > (pauseSec * 1000)) {
                      checkStartCondition(); 
                  }
              }
          } else {
              isBottlePresent = false;
              if (isAutoArmed) strlcpy(machineStatus, "ARMED: WAIT TARE", sizeof(machineStatus));
          }
      }
      break;

    case STATE_EMERGENCY_LIFT:
      if (isSensorActive(PIN_IN_HEAD_UP)) {
        strlcpy(machineStatus, "STOP: OK (MANUAL TRAY)", sizeof(machineStatus));
        changeState(STATE_IDLE);
      }
      break;

    case STATE_TRAY_MOVING:
      if (millis() - stateTimer > 5000) { stopAll(); strlcpy(machineStatus, "ERR: TRAY TIMEOUT", sizeof(machineStatus)); break; } 
      if (isSensorActive(PIN_IN_TRAY)) {
        digitalWrite(PIN_HEAD, HIGH); 
        strlcpy(machineStatus, "HEAD DOWN...", sizeof(machineStatus));
        changeState(STATE_HEAD_MOVING);
      }
      break;

    case STATE_HEAD_MOVING:
      if (millis() - stateTimer > 5000) { stopAll(); strlcpy(machineStatus, "ERR: HEAD TIMEOUT", sizeof(machineStatus)); break; } 

      if (isSensorActive(PIN_IN_HEAD_DOWN)) {
          if (millis() - stateTimer > 150) { 
              digitalWrite(PIN_NOZZLE, HIGH);  
              digitalWrite(PIN_PRODUCT, HIGH); 
              digitalWrite(PIN_PUMP_MAIN, HIGH); 
              
              strlcpy(machineStatus, "FILLING...", sizeof(machineStatus));
              changeState(STATE_FILLING);
          }
      } else {
          stateTimer = millis(); 
      }
      break;
      
    case STATE_FILLING: 
      break;

    case STATE_HEAD_LIFTING:
       if (millis() - stateTimer < 500) break; 
       if (isSensorActive(PIN_IN_HEAD_UP)) {
           delay(100); 
           if (isSensorActive(PIN_IN_HEAD_UP)) {
               digitalWrite(PIN_DRIP, LOW); 
               strlcpy(machineStatus, "TRAY OUT...", sizeof(machineStatus));
               changeState(STATE_TRAY_RETURN);
           }
       }
       break;

    case STATE_TRAY_RETURN:
      if (millis() - stateTimer > 1000) {
        totalBottles++;
        strlcpy(machineStatus, "REMOVE BOTTLE", sizeof(machineStatus));
        isAutoMode = false; 
        changeState(STATE_WAIT_REMOVAL);
      }
      break;

    case STATE_WAIT_REMOVAL:
      if (weightRealDisplay.load(std::memory_order_relaxed) < 20.0) {
          if (millis() - stateTimer > 1000) {
              strlcpy(machineStatus, "IDLE", sizeof(machineStatus));
              isBottlePresent = false; 
              changeState(STATE_IDLE);
          }
      } else {
          stateTimer = millis();
      }
      break;
      
    case STATE_WASH_PREPARE:
      if (millis() - stateTimer > 5000) { stopAll(); strlcpy(machineStatus, "ERR: TRAY TIMEOUT", sizeof(machineStatus)); break; }
      if (isSensorActive(PIN_IN_TRAY)) {
        digitalWrite(PIN_HEAD, HIGH); 
        changeState(STATE_WASHING);
      }
      break;

    case STATE_WASHING:
      if (isSensorActive(PIN_IN_HEAD_DOWN)) {
          digitalWrite(PIN_WATER, HIGH);
          digitalWrite(PIN_PRODUCT, HIGH); 
          digitalWrite(PIN_NOZZLE, HIGH); 
          digitalWrite(PIN_PUMP_MAIN, HIGH);
          if (millis() - stateTimer > (washSec * 1000)) stopAll(); 
      }
      break;
      
    case STATE_WASH_FINISH: break;

    case STATE_BLOW_PREPARE:
      if (millis() - stateTimer > 5000) { stopAll(); strlcpy(machineStatus, "ERR: TRAY TIMEOUT", sizeof(machineStatus)); break; }
      if (isSensorActive(PIN_IN_TRAY)) { 
        digitalWrite(PIN_HEAD, HIGH); 
        changeState(STATE_BLOWING_PROCESS);
      }
      break;

    case STATE_BLOWING_PROCESS:
      if (isSensorActive(PIN_IN_HEAD_DOWN)) {
        digitalWrite(PIN_AIR, HIGH); 
        if (millis() - stateTimer > (blowSec * 1000)) stopAll(); 
      }
      break;

    case STATE_BLOW_FINISH: break;
  }

  static unsigned long t = 0;
  if (millis() - t > 400) {
    char jsonBuf[384];
    
    float displayW;
    float currentReal = weightRealDisplay.load(std::memory_order_relaxed);
    
    if (currentState == STATE_FILLING) {
        float offset = atomicWeightOffset.load(std::memory_order_relaxed);
        displayW = currentReal - offset; 
    } else {
        displayW = currentReal; 
    }
    
    snprintf(jsonBuf, sizeof(jsonBuf), 
      "{\"w\":\"%.0f\",\"cnt\":\"%d\",\"st\":\"%s\",\"s\":\"%d%d%d\",\"o\":\"%d%d%d%d%d%d%d\",\"auto\":%s,\"cfg\":{\"t\":%.0f,\"tar\":%.0f,\"p\":%.1f,\"b\":%d,\"w\":%d,\"k\":%.2f}}",
      displayW, totalBottles, machineStatus, 
      isSensorActive(PIN_IN_HEAD_UP), isSensorActive(PIN_IN_TRAY), isSensorActive(PIN_IN_HEAD_DOWN),
      digitalRead(PIN_DRIP), digitalRead(PIN_HEAD), digitalRead(PIN_NOZZLE), digitalRead(PIN_PUMP_MAIN),
      digitalRead(PIN_PRODUCT), digitalRead(PIN_AIR), digitalRead(PIN_WATER),
      isAutoArmed ? "true" : "false",
      atomicTargetWeight.load(std::memory_order_relaxed), expectedTare, pauseSec, blowSec, washSec, calibrationFactor
    );
    
    ws.textAll(jsonBuf);
    t = millis();
  }
}

(Special thanks @PaulRB and @build_1971 for the tips on how to make this code better)

I know the physical wiring is a bit of a mess, but considering it works perfectly for the first 20 cycles, I want to know if soldering the capacitors to the ESP32 and/or upgrading the PSU to 2A is the most logical first step to fix this thermal/time-based drift.

Any advice, harsh truths, or tips for a newbie are highly appreciated! Thank you!

HI @kitamaro33
Load cells are temperature sensitive.

2 Likes

Those peaks are extremely short. The average current drawn by the ESP is probably around 80mA. I doubt your PSU is overheating. But maybe it can't actually produce the 0.6A it is rated for? Certainly worth trying alternative models, if only to eliminate the PSU from your investigation.

Thanks for the input! I am aware that load cells are temperature sensitive. However, doesn't thermal drift usually manifest as a slow, steady creeping of the zero point over time?

What I am experiencing after 10 minutes is rapid, random scatter/noise ( +-30g jumping between consecutive 15-second fills), not a slow creeping offset. Also, the load cell itself is mounted outside the main enclosure and remains at room temperature.

Thanks for the input! That makes a lot of sense. I'll definitely swap the PSU for a reliable 2A model.What do you think about adding two capacitors directly to the ESP32 5V line: a 1000uF standard electrolytic to buffer the WiFi peaks, and a 0.1uF (100nF) ceramic for high-frequency decoupling?Also, one more question: considering the unshielded load cell wires run somewhat close to the VFD, could EMI or heat buildup in the VFD/cabinet also cause this 10-minute delayed drift, or does this sound strictly like a PSU voltage ripple issue to you?

2A is overkill. 1A is more than adequate. But if you have a 2A around already, give it a try.

The 1000uF is worth trying, but probably overkill. I expect the ESP board already has adequate decoupling.

Can't say for sure, but it doesn't sound like either of those things to me. Like you said before, those things would either start affecting the circuit immediately from cold, or they would gradually get worse over time. But your circuit performs well for 10 mins.

1 Like

If you reset the ESP, not allowing it to cool, does it work for another 20 cycles? Is it always exactly 20 cycles?

2 Likes
Could my code be causing this drift over time?

You did mention drift, however your symtoms could be caused by a bad solder joint, cracked trace on a PCB, a loose connection or an electrical connection between dissimilar metal wires (Copper/Aluminum).
All of which will be affected by temperature.

2 Likes

Indiscriminately throwing in large capacitors for no reason could do more harm than good.

1 Like

To be completely honest, I did not try doing a quick "hot reset" (rebooting the ESP without letting the cabinet cool down). So I don't know for sure if a quick software reset clears the issue instantly. Also, "20 cycles" was an approximation (it was around 18 to 22 bottles before the weight readings started drifting and jumping wildly).
However, your logic makes sense:

  1. If a quick reset fixes it while the hardware is still hot ,it's a software/memory leak or variable overflow.
  2. If it remains glitchy after a reset until it physically cools down , it's a hardware/thermal/PSU issue.
    Hope I got your idea right,
    I will perform this exact "hot reset" test the next time I am at the machine.
    In the meantime, since you pointed out it might be software-related after a certain time/cycle limit: does the code I posted above show any obvious memory leaks, FreeRTOS task overflows, or variable issues (like in my weightTaskCore or the WebSocket handling) that typically trigger after ~10 minutes of running?
1 Like

Again you mention drift. Exactly what are the errors you are seeing.

Thanks for the answer, and honestly it makes sense, because I think I could have made a mistake when soldering. Regarding the connection of dissimilar wires - an aluminum wire goes from the HX711 itself to the ESP32U, clamped into a terminal, from which a small silicone wire goes to the PSU and the ESP32U itself, I don't know if this could be the problem. Can you please explain to me how exactly I can test both theories?

My apologies for the poor choice of words! You are completely right to call me out on that.

By "drift" I did not mean a slow zero-point thermal drift of the load cell. When the machine is empty, the scale stays perfectly at 0g.

What I actually meant is dispense scatter (inconsistency in the final poured weight) and erratic real-time readings.

Here is exactly what happens:

  1. First 20 bottles (Cold): The final weight of the filled bottles is beautifully consistent (e.g., 5002g, 5005g, 5000g, 5004g). The real-time numbers on the screen climb smoothly as the liquid flows.
  2. After ~10 minutes (Hot): The final poured weights start varying wildly (e.g., 5000g, 5045g, 4960g, 5070g). It's as if the valve closes at random unpredictable delays. At the exact same time, the real-time weight displayed on the screen during the fill might stutter, freeze for a fraction of a second, or jump erratically instead of climbing smoothly.

So the "error" is a sudden loss of closing-time precision, accompanied by what looks like noisy/stuttering ADC readings. Does this point more towards the PSU/Capacitor theory, or code execution delays?

Freeze spray and a hair dryer but I would just replace any aluminum or copper/aluminum wires you have with 100% copper wires.

Bad soldering:

Also many of the HX711 boards are of low quality.

1 Like

It's a lot of code to review. I'll try to work my way through it when/if I have time. I'm not familiar with FreeRTOS.

But to prove I am reading through it, here's a suggestion for shortening your code:

void checkSecurity() {
  uint8_t chipMac[6];
  WiFi.macAddress(chipMac);
  if (memcmp(chipMac, targetMac, sizeof(chipMac)) != 0) {
    while (true) { digitalWrite(PIN_PUMP_MAIN, LOW); delay(100); }
  }
}

(Untested!)

1 Like

Thanks for the tip! I will insert this section and test it out. I realize dropping 600+ lines of code is a lot to ask someone to review in their free time. Even if you don't get a chance to look deeper into it, I completely understand and I really appreciate the improvement you've already shared!

1 Like

How hot does it get???
Can you feel the heat?
Usually an esp should be maybe 10 deg warmer than surroundings.
Regulator could be hotter...
But usually not above painfull hot..
I would not be surprised if you somehow overfill memory due to a memory leakage. You might add serialprints of stack height and heap memory in use... your html text is long. If you handle this in a bad manner, you can quickly loose a lot of memory. I work from a phone now, so could not check your String handling.

1 Like

Make this every 60 minutes and see what happens...(after 10 or 60 minutes)

1 Like
    long count = 0;
    portENTER_CRITICAL(&mux);
    for (int i = 0; i < 24; i++) {
      GPIO.out_w1ts = (1 << HX_COMMON_SCK); delayMicroseconds(1); 
      count = count << 1;
      if (GPIO.in & (1 << HX_DT_PLATFORM)) count++; 
      GPIO.out_w1tc = (1 << HX_COMMON_SCK); delayMicroseconds(1);
    }
    GPIO.out_w1ts = (1 << HX_COMMON_SCK); delayMicroseconds(1);
    GPIO.out_w1tc = (1 << HX_COMMON_SCK); 
    portEXIT_CRITICAL(&mux);
    count ^= 0x800000;

Is there a reason why you need to go so fast?

1 Like

Nice presentation... (my computer does not have tilting ability... so I turned the image).

1 Like