#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include "HX711.h"
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <math.h>
#ifndef WIFI_SSID
#define WIFI_SSID "Wokwi-GUEST"
#endif
#ifndef WIFI_PASS
#define WIFI_PASS ""
#endif
#define MQTT_HOST "test.mosquitto.org"
#define MQTT_PORT 1883
const char* TOPIC_TELE = "kitchen/esp32dev1/telemetry";
const char* TOPIC_CMD = "kitchen/esp32dev1/cmd";
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
#define OLED_RESET -1
#define DS18B20_PIN 4
#define HX711_DOUT 19
#define HX711_SCK 18
#define BUZZER_PIN 15
enum UnitMode { UNIT_G, UNIT_KG };
UnitMode currentUnit = UNIT_KG;
static inline const char* getUnitString() { return currentUnit == UNIT_KG ? "kg" : "g"; }
static inline float convertToDisplay(float grams) { return currentUnit == UNIT_KG ? (grams / 1000.0f) : grams; }
static inline float convertToGrams(float value) { return currentUnit == UNIT_KG ? (value * 1000.0f) : value; }
float scaleFactor = 0.20f;
long scaleOffset = 0;
struct CalPoint { bool set=false; float grams=0; long raw=0; } point1, point2;
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
WebServer webServer(80);
OneWire oneWireBus(DS18B20_PIN);
DallasTemperature tempSensor(&oneWireBus);
HX711 weightSensor;
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_RESET);
enum TimerStatus { TIMER_IDLE, TIMER_RUNNING, TIMER_DONE };
TimerStatus timerStatus = TIMER_IDLE;
uint32_t timerLengthMs = 0, timerBeginMs = 0;
float currentTempC = NAN;
float currentWeightG = NAN;
float displayZeroG = 0.0f;
uint32_t lastSendTime = 0;
const uint32_t SEND_INTERVAL_MS = 3000;
String formatTime(uint32_t milliseconds) {
uint32_t seconds = (milliseconds + 999) / 1000;
char timeBuffer[8];
snprintf(timeBuffer, sizeof(timeBuffer), "%02u:%02u", (unsigned)(seconds/60), (unsigned)(seconds%60));
return String(timeBuffer);
}
String generateTip(float weightG, float tempC) {
if (weightG >= 10 && weightG <= 60 && tempC >= 85 && tempC <= 96) return "Pour-over (90-96C, 2-3min).";
if (weightG >= 400 && weightG <= 2000 && tempC >= 25 && tempC <= 35) return "Dough proof (27-32C).";
if (tempC >= 55 && tempC <= 65 && weightG >= 200 && weightG <= 1500) return "Keep warm / sous-vide ~60C.";
if (weightG <= 5 && tempC < 15) return "Tip: empty scale? Tare.";
float score = 0.01f*weightG + 0.05f*tempC - 1.0f;
if (score < 0.5f) return "Basic: weighing.";
if (score < 1.5f) return "Standard: timer/brew.";
return "Advanced: temp & time.";
}
String createTelemetryJson() {
StaticJsonDocument<512> jsonDoc;
if (isfinite(currentWeightG)) {
float displayWeight = currentWeightG - displayZeroG;
jsonDoc["weight_g"] = currentWeightG;
jsonDoc["weight_shown"] = convertToDisplay(displayWeight);
}
if (isfinite(currentTempC)) jsonDoc["temp_c"] = currentTempC;
jsonDoc["unit"] = getUnitString();
jsonDoc["soft_zero_g"] = displayZeroG;
jsonDoc["scale_factor"] = scaleFactor;
jsonDoc["scale_offset"] = scaleOffset;
const char* statusText = (timerStatus==TIMER_IDLE? "idle" : timerStatus==TIMER_RUNNING? "running" : "done");
jsonDoc["timer_state"] = statusText;
if (timerStatus == TIMER_RUNNING) {
uint32_t now=millis(), endTime=timerBeginMs+timerLengthMs;
int32_t remaining = (endTime>now)? (endTime-now) : 0;
jsonDoc["timer_left_s"] = (remaining+999)/1000;
} else jsonDoc["timer_left_s"]=0;
jsonDoc["ai"] = generateTip(currentWeightG, currentTempC);
String output; serializeJson(jsonDoc,output); return output;
}
void updateDisplay() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
float shownWeight = isfinite(currentWeightG) ? (currentWeightG - displayZeroG) : NAN;
display.setCursor(0,0); display.print("W: ");
if (isfinite(shownWeight)) {
float displayValue = convertToDisplay(shownWeight);
if (currentUnit == UNIT_KG) display.print(displayValue,3);
else display.print(displayValue,1);
display.print(" ");
display.print(getUnitString());
if (fabsf(displayZeroG) > 0.001f) { display.print(" (Z)"); }
} else {
display.print("-- ");
display.print(getUnitString());
}
display.setCursor(0,16); display.print("T: ");
if (isfinite(currentTempC)) { display.print(currentTempC,1); display.print(" C"); } else display.print("--.- C");
display.setCursor(0,32); display.print("Timer: ");
if (timerStatus==TIMER_RUNNING){
uint32_t now=millis(), endTime=timerBeginMs+timerLengthMs;
uint32_t remaining=(endTime>now)?(endTime-now):0; display.print(formatTime(remaining));
} else if (timerStatus==TIMER_DONE) display.print("DONE");
else display.print("--:--");
display.setCursor(0,48); display.print("AI: ");
display.setCursor(18,48);
String message=generateTip(currentWeightG,currentTempC); if(message.length()>19) message=message.substring(0,19);
display.print(message);
display.display();
}
void soundBeep(uint8_t count=2){
for(uint8_t i=0;i<count;i++){ tone(BUZZER_PIN,2000,150); delay(200); noTone(BUZZER_PIN); delay(100); }
}
void mqttMessageCallback(char* topic, byte* payload, unsigned int length){
String message; message.reserve(length); for(unsigned i=0;i<length;i++) message+=(char)payload[i];
StaticJsonDocument<256> doc; if(deserializeJson(doc,message)) return;
const char* command=doc["cmd"]|"";
if(!strcmp(command,"tare")){
bool hardwareTare = doc["hard"] | false;
if (hardwareTare) { weightSensor.tare(); scaleOffset=weightSensor.get_offset(); displayZeroG=0; }
else { displayZeroG = currentWeightG; }
}
else if(!strcmp(command,"unzero")) { displayZeroG = 0.0f; }
else if(!strcmp(command,"timer_start")){ uint32_t seconds=doc["sec"]|60; timerLengthMs=seconds*1000UL; timerBeginMs=millis(); timerStatus=TIMER_RUNNING; }
else if(!strcmp(command,"timer_stop")) { timerStatus=TIMER_IDLE; }
else if(!strcmp(command,"set_cal")) { float newFactor=doc["value"]|scaleFactor; scaleFactor=newFactor; weightSensor.set_scale(scaleFactor); }
}
void maintainMqtt(){
while(!mqttClient.connected()){
String clientId="ESP32Kitchen-"+String((uint32_t)ESP.getEfuseMac(),HEX);
if(mqttClient.connect(clientId.c_str())) mqttClient.subscribe(TOPIC_CMD); else delay(1200);
}
}
const char* WEB_PAGE =
"<!doctype html><html><head><meta name='viewport' content='width=device-width, initial-scale=1'/>"
"<title>Kitchen Device</title>"
"<style>body{font-family:system-ui,Arial;padding:16px;max-width:720px;margin:auto}"
".card{border:1px solid #ddd;border-radius:10px;padding:16px;margin:12px 0}"
".btn{display:inline-block;padding:8px 12px;border:1px solid #444;border-radius:6px;text-decoration:none;background:#fafafa}"
".row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}"
".small{color:#666;font-size:12px}label{display:inline-block;min-width:120px}input[type=number]{width:140px}"
"code{background:#f2f2f2;padding:2px 4px;border-radius:4px}</style></head><body>"
"<h2>ESP32 Kitchen Device</h2>"
"<div class='row'>"
" <a class='btn' href='/tare'>Soft Zero (Tare)</a>"
" <a class='btn' href='/unzero'>Unzero</a>"
" <a class='btn' href='/hw_tare'>HW Tare (careful!)</a>"
"</div>"
"<div class='row'><span>Units:</span> <a class='btn' href='/units?u=kg'>kg</a> <a class='btn' href='/units?u=g'>g</a>"
" <a class='btn' href='/preset_wokwi_5kg'>Preset: Wokwi 5kg</a></div>"
"<div class='card'><h3>Two-point calibration</h3>"
"<p>Enter values in the <b>current unit</b> (match Wokwi slider). If slider shows <b>kg</b>, use kg here.</p>"
"<p>Step 1: set known weight #1 and press:</p>"
"<form action='/cal_step1' method='get' class='row'><label>w1</label><input type='number' step='any' name='w1' min='0' max='5000' value='0'/>"
"<button class='btn'>CAL STEP 1</button></form>"
"<p>Step 2: set known weight #2 and press:</p>"
"<form action='/cal_step2' method='get' class='row'><label>w2</label><input type='number' step='any' name='w2' min='0.001' max='5000' value='1.000'/>"
"<button class='btn'>CAL STEP 2</button></form>"
"<p class='small'>After STEP 2, factor & offset are computed.</p>"
"</div>"
"<div class='card' id='live'><h3>Live</h3>"
"<div>Weight: <span id='w'>--</span> <span id='wu'>kg</span> <span id='zind' class='small'></span></div>"
"<div>Temp: <span id='t'>--</span> C</div>"
"<div>Timer: <span id='ti'>--:--</span> (<span id='ts'>state</span>)</div>"
"<div>AI: <span id='ai'>...</span></div>"
"<div class='small'>scale_factor(counts/g)=<span id='sf'>?</span>, offset=<span id='so'>?</span>, soft_zero_g=<span id='sz'>0</span></div>"
"</div>"
"<div class='card'><h3>Debug</h3>"
"<p><a class='btn' href='/raw'>/raw</a> <a class='btn' href='/invert'>/invert</a> multiply factor: <code>/mul?x=2</code></p>"
"</div>"
"<script>function poll(){fetch('/api').then(r=>r.json()).then(j=>{"
"var unit=j.unit||'kg';var wd=j.weight_shown;"
"document.getElementById('w').textContent=(typeof wd==='number')?(unit==='kg'?wd.toFixed(3):wd.toFixed(1)):'--';"
"document.getElementById('wu').textContent=unit;"
"document.getElementById('zind').textContent=(j.soft_zero_g && Math.abs(j.soft_zero_g)>0.001)?'(Z on)':'';"
"document.getElementById('sz').textContent=(j.soft_zero_g||0).toFixed(1);"
"var t=j.temp_c;document.getElementById('t').textContent=(typeof t==='number')?t.toFixed(1):'--';"
"document.getElementById('ai').textContent=j.ai||'...';"
"document.getElementById('ts').textContent=j.timer_state||'idle';"
"var s=j.timer_left_s||0,m=Math.floor(s/60),ss=('0'+(s%60)).slice(-2);"
"document.getElementById('ti').textContent=('0'+m).slice(-2)+':'+ss;"
"document.getElementById('sf').textContent=(j.scale_factor!==undefined)?j.scale_factor.toFixed(6):'?';"
"document.getElementById('so').textContent=(j.scale_offset!==undefined)?j.scale_offset:'?';"
"}).catch(()=>{}).finally(()=>setTimeout(poll,1000));}poll();</script>"
"</body></html>";
void setupWebServer(){
webServer.on("/", HTTP_GET, [](){ webServer.send(200,"text/html; charset=utf-8",WEB_PAGE); });
webServer.on("/api", HTTP_GET, [](){ webServer.send(200,"application/json",createTelemetryJson()); });
webServer.on("/units", HTTP_GET, [](){
if(!webServer.hasArg("u")){ webServer.send(400,"text/plain","Missing u (kg|g)"); return; }
String unit = webServer.arg("u");
if (unit=="kg") currentUnit=UNIT_KG;
else if (unit=="g") currentUnit=UNIT_G;
webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK");
});
webServer.on("/tare", HTTP_GET, [](){
displayZeroG = currentWeightG;
webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK");
});
webServer.on("/unzero", HTTP_GET, [](){
displayZeroG = 0.0f;
webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK");
});
webServer.on("/hw_tare", HTTP_GET, [](){
weightSensor.tare(); scaleOffset = weightSensor.get_offset();
displayZeroG = 0.0f;
webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK");
});
webServer.on("/preset_wokwi_5kg", HTTP_GET, [](){
scaleFactor = 0.20f;
weightSensor.set_scale(scaleFactor);
webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK");
});
webServer.on("/cal_step1", HTTP_GET, [](){
if(!webServer.hasArg("w1")){ webServer.send(400,"text/plain","Missing w1"); return; }
float weight1Display = webServer.arg("w1").toFloat();
point1.grams = convertToGrams(weight1Display);
delay(300);
point1.raw = weightSensor.read_average(20);
point1.set = true;
String response = String("STEP1: w1=") + String(weight1Display,3) + " " + getUnitString() + String(", raw1=") + String(point1.raw);
webServer.send(200,"text/plain",response);
});
webServer.on("/cal_step2", HTTP_GET, [](){
if(!webServer.hasArg("w2")){ webServer.send(400,"text/plain","Missing w2"); return; }
if(!point1.set){ webServer.send(400,"text/plain","Do STEP 1 first"); return; }
float weight2Display = webServer.arg("w2").toFloat();
point2.grams = convertToGrams(weight2Display);
delay(300);
point2.raw = weightSensor.read_average(20);
point2.set = true;
float gramDifference = point2.grams - point1.grams;
if (fabs(gramDifference) < 0.001f){ webServer.send(400,"text/plain","w2 must differ from w1"); return; }
float newFactor = (float)(point2.raw - point1.raw) / gramDifference;
long newOffset = (long) llround( (double)point1.raw - (double)newFactor * (double)point1.grams );
scaleFactor = newFactor;
scaleOffset = newOffset;
weightSensor.set_scale(scaleFactor);
weightSensor.set_offset(scaleOffset);
displayZeroG = 0.0f;
String response = String("STEP2: w2=") + String(weight2Display,3) + " " + getUnitString() + String(", raw2=") + String(point2.raw) +
String("\nscale(counts/g)=") + String(scaleFactor,6) + String(", offset=") + String(scaleOffset);
webServer.send(200,"text/plain",response);
});
webServer.on("/invert", HTTP_GET, [](){
scaleFactor = -scaleFactor; weightSensor.set_scale(scaleFactor);
webServer.send(200,"text/plain",String("factor(counts/g)=")+String(scaleFactor,6));
});
webServer.on("/mul", HTTP_GET, [](){
if(!webServer.hasArg("x")){ webServer.send(400,"text/plain","Missing x"); return; }
float multiplier=webServer.arg("x").toFloat(); if(!isfinite(multiplier)||fabs(multiplier)<1e-4){ webServer.send(400,"text/plain","Bad x"); return; }
scaleFactor*=multiplier; weightSensor.set_scale(scaleFactor); webServer.send(200,"text/plain",String("factor(counts/g)=")+String(scaleFactor,6));
});
webServer.on("/raw", HTTP_GET, [](){
long average=weightSensor.read_average(10);
long offset=weightSensor.get_offset();
long value=weightSensor.get_value(10);
float units=weightSensor.get_units(5);
String jsonData=String("{\"read_avg\":")+String(average)+",\"offset\":"+String(offset)+",\"value\":"+String(value)+",\"units5\":"+String(units,4)+",\"factor\":"+String(scaleFactor,6)+"}";
webServer.send(200,"application/json",jsonData);
});
webServer.on("/timer_start", HTTP_GET, [](){
uint32_t seconds=120; if(webServer.hasArg("sec")) seconds=webServer.arg("sec").toInt();
timerLengthMs=seconds*1000UL; timerBeginMs=millis(); timerStatus=TIMER_RUNNING;
webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK");
});
webServer.on("/timer_stop", HTTP_GET, [](){ timerStatus=TIMER_IDLE; webServer.sendHeader("Location","/"); webServer.send(302,"text/plain","OK"); });
webServer.begin();
}
void setup(){
Serial.begin(115200); delay(200);
Wire.begin(21,22);
display.begin(SSD1306_SWITCHCAPVCC,0x3C);
display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE);
display.setCursor(0,0); display.println("Kitchen Device"); display.display();
pinMode(DS18B20_PIN, INPUT_PULLUP);
tempSensor.begin();
weightSensor.begin(HX711_DOUT, HX711_SCK);
weightSensor.set_scale(scaleFactor);
weightSensor.set_offset(scaleOffset);
weightSensor.tare();
scaleOffset = weightSensor.get_offset();
pinMode(BUZZER_PIN, OUTPUT); noTone(BUZZER_PIN);
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("WiFi"); while(WiFi.status()!=WL_CONNECTED){ delay(300); Serial.print("."); }
Serial.println(); Serial.print("IP: "); Serial.println(WiFi.localIP());
mqttClient.setServer(MQTT_HOST, MQTT_PORT); mqttClient.setCallback(mqttMessageCallback); maintainMqtt();
setupWebServer();
}
void loop(){
if(WiFi.status()!=WL_CONNECTED) WiFi.reconnect();
if(!mqttClient.connected()) maintainMqtt();
mqttClient.loop();
webServer.handleClient();
if (weightSensor.is_ready()) {
currentWeightG = weightSensor.get_units(1);
}
tempSensor.requestTemperatures();
float temperature = tempSensor.getTempCByIndex(0); if (temperature>-100 && temperature<150) currentTempC=temperature;
if (timerStatus==TIMER_RUNNING){
uint32_t now=millis(), endTime=timerBeginMs+timerLengthMs;
if (now>=endTime){ timerStatus=TIMER_DONE; soundBeep(3); }
}
updateDisplay();
uint32_t currentTime=millis();
if (currentTime-lastSendTime>=SEND_INTERVAL_MS){
lastSendTime=currentTime; String jsonData=createTelemetryJson(); mqttClient.publish(TOPIC_TELE, jsonData.c_str(), true); Serial.println(jsonData);
}
delay(20);
}