// WAGA ESP32 + HX711 + LCD1602 I2C + WEB AP
// AP: SSID="waga", PASS="waga" (jeśli pass <8 -> AP otwarte)
// Po polaczeniu: http://192.168.4.1/
#include <Arduino.h>
#include <HX711.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Preferences.h>
#include <WiFi.h>
#include <WebServer.h>
#define DOUT_PIN 4
#define SCK_PIN 5
#define I2C_SDA 21
#define I2C_SCL 22
#define LCD_ADDR 0x27
#define BTN_TARE 18
#define BTN_CAL 19
const uint8_t SAMPLE_COUNT = 3;
const unsigned long READ_INTERVAL_MS = 60;
const unsigned long LONG_PRESS_MS = 1000;
const unsigned long DEBOUNCE_MS = 40;
HX711 scale;
LiquidCrystal_I2C lcd(LCD_ADDR, 16, 2);
Preferences prefs;
WebServer server(80);
// ustawienia / stan
float calFactor = 0.0f;
long storedOffset = 0;
int unitSel = 0; // 0=g,1=dkg,2=kg
bool highRes = false; // false = 1 g, true = 0.1 g
int selWeightIndex = 6; // domyslnie 5000 g (index 6 w tablicy)
// dostepne odwazniki (g)
const int weightOptions[] = {50, 100, 200, 500, 1000, 2000, 5000};
const int weightOptionsCount = sizeof(weightOptions) / sizeof(weightOptions[0]);
// decimals per unit [unit][resIdx]
const uint8_t fmtDecimals[3][2] = {
{0,1}, // g
{1,2}, // dkg
{3,4} // kg
};
enum AppState {
ST_IDLE,
ST_MENU,
ST_CAL_SELECT_WEIGHT,
ST_CAL_WAIT_EMPTY_TARE,
ST_CAL_WAIT_PLACE_WEIGHT,
ST_CAL_DONE
};
AppState state = ST_IDLE;
int menuIndex = 0; // 0..2
// przyciski
struct Btn { uint8_t pin; bool lastState; unsigned long downMs; bool longHandled; };
Btn btnT, btnC;
// tmp message
char tmpMsg[17];
unsigned long tmpMsgExpiry = 0; // 0 = indefinite
// lcd buffers (dokladnie 16 znakow)
char lastLine1[17], lastLine2[17];
// ---------- helpers ----------
void saveConfig() {
prefs.putFloat("cal", calFactor);
prefs.putLong("off", storedOffset);
prefs.putInt("unit", unitSel);
prefs.putInt("res", highRes ? 1 : 0);
prefs.putInt("widx", selWeightIndex);
}
void showTempMessage(const char* s, unsigned long timeoutMs = 2000) {
// copy up to 16 chars and pad with spaces
memset(tmpMsg, ' ', 16);
tmpMsg[16] = '\0';
size_t L = strlen(s);
if (L > 16) L = 16;
memcpy(tmpMsg, s, L);
if (timeoutMs == 0) tmpMsgExpiry = 0;
else tmpMsgExpiry = millis() + timeoutMs;
}
void clearTempMessage() {
memset(tmpMsg, ' ', 16);
tmpMsg[16] = '\0';
tmpMsgExpiry = 0;
}
bool isTempMessageActive() {
bool notEmpty = memcmp(tmpMsg, " ", 16) != 0;
return notEmpty && (tmpMsgExpiry == 0 || millis() < tmpMsgExpiry);
}
void fillBlank(char* line) {
for (int i = 0; i < 16; ++i) line[i] = ' ';
line[16] = '\0';
}
void printLineIfChanged(int row, const char* line16) {
char* last = (row == 0) ? lastLine1 : lastLine2;
if (memcmp(last, line16, 16) != 0) {
memcpy(last, line16, 16);
last[16] = '\0';
lcd.setCursor(0, row);
// write exactly 16 characters (including spaces)
for (int i = 0; i < 16; ++i) lcd.write((uint8_t)line16[i]);
}
}
long pow10int(uint8_t p) {
long r = 1;
while (p--) r *= 10;
return r;
}
// minimal format (no leading zeros). out buffer must be provided.
void formatMinimal(double valueUnit, uint8_t decimals, char* out, size_t maxlen) {
long mult = (decimals == 0) ? 1 : pow10int(decimals);
long disp = (long) llround(valueUnit * (double)mult);
bool neg = false;
if (disp < 0) { neg = true; disp = -disp; }
long intPart = disp / mult;
long fracPart = disp % mult;
if (decimals == 0) {
if (neg) snprintf(out, maxlen, "-%ld", intPart);
else snprintf(out, maxlen, "%ld", intPart);
return;
}
char intStr[24];
snprintf(intStr, sizeof(intStr), "%ld", intPart);
char frac[16];
for (int i = decimals - 1; i >= 0; --i) {
frac[i] = '0' + (fracPart % 10);
fracPart /= 10;
}
frac[decimals] = '\0';
if (neg) snprintf(out, maxlen, "-%s.%s", intStr, frac);
else snprintf(out, maxlen, "%s.%s", intStr, frac);
}
void putCentered(char* line, const char* s) {
fillBlank(line);
int L = strlen(s);
if (L > 16) L = 16;
int start = (16 - L) / 2;
memcpy(line + start, s, L);
}
// ---------- displays ----------
void updateDisplay_Normal() {
char top[17], bot[17];
fillBlank(top); fillBlank(bot);
putCentered(top, "WAGA");
if (isTempMessageActive()) {
memcpy(bot, tmpMsg, 16);
} else {
if (calFactor > 0.0f) {
float grams = scale.get_units(SAMPLE_COUNT);
double divisor = (unitSel == 0 ? 1.0 : (unitSel == 1 ? 10.0 : 1000.0));
double valueUnit = grams / divisor;
uint8_t decimals = fmtDecimals[unitSel][highRes ? 1 : 0];
char num[24];
formatMinimal(valueUnit, decimals, num, sizeof(num));
char combined[32];
snprintf(combined, sizeof(combined), "%s %s", num, (unitSel == 0 ? "g" : (unitSel == 1 ? "dkg" : "kg")));
int len = strlen(combined);
int start = (16 - len) / 2;
if (start < 0) start = 0;
memcpy(bot + start, combined, len);
} else {
putCentered(bot, "BRAK KALIBRACJI");
}
}
printLineIfChanged(0, top);
printLineIfChanged(1, bot);
}
void updateDisplay_Menu() {
char top[17], bot[17];
fillBlank(top); fillBlank(bot);
if (menuIndex == 0) {
putCentered(top, "JEDNOSTKA");
// bottom: " gr dkg kg" with arrows on left
bot[2] = 'g'; bot[3] = 'r';
bot[7] = 'd'; bot[8] = 'k'; bot[9] = 'g';
bot[12] = 'k'; bot[13] = 'g';
bot[1] = (unitSel == 0) ? '>' : ' ';
bot[6] = (unitSel == 1) ? '>' : ' ';
bot[11] = (unitSel == 2) ? '>' : ' ';
} else if (menuIndex == 1) {
putCentered(top, "DOKLADNOSC");
if (!highRes) putCentered(bot, "PELNE GRAMY");
else putCentered(bot, "10/GRAM");
} else if (menuIndex == 2) {
putCentered(top, "ODWAZNIK");
char b[17]; snprintf(b, 17, "%d g", weightOptions[selWeightIndex]);
putCentered(bot, b);
}
printLineIfChanged(0, top);
printLineIfChanged(1, bot);
}
void updateDisplay_Calibration() {
char top[17], bot[17];
fillBlank(top); fillBlank(bot);
if (state == ST_CAL_SELECT_WEIGHT) {
putCentered(top, "KALIBRACJA");
char b[17]; snprintf(b, 17, "WAGA: %dg", weightOptions[selWeightIndex]);
putCentered(bot, b);
} else if (state == ST_CAL_WAIT_EMPTY_TARE) {
putCentered(top, "USUN WSZYSTKO");
putCentered(bot, "WCISNIJ TARA");
} else if (state == ST_CAL_WAIT_PLACE_WEIGHT) {
putCentered(top, "POLOZ ODWAZNIK");
putCentered(bot, "NACISNIJ KAL");
} else if (state == ST_CAL_DONE) {
putCentered(top, "KALIBRACJA OK");
putCentered(bot, "ZAPISANO");
}
printLineIfChanged(0, top);
printLineIfChanged(1, bot);
}
// ---------- buttons / actions ----------
void performTareSilently() {
scale.tare(15);
storedOffset = scale.get_offset();
prefs.putLong("off", storedOffset);
}
void onTareShort() {
if (state == ST_MENU) {
if (menuIndex == 0) {
unitSel = (unitSel + 1) % 3;
prefs.putInt("unit", unitSel);
updateDisplay_Menu();
} else if (menuIndex == 1) {
highRes = !highRes;
prefs.putInt("res", highRes ? 1 : 0);
updateDisplay_Menu();
} else if (menuIndex == 2) {
selWeightIndex++; if (selWeightIndex >= weightOptionsCount) selWeightIndex = 0;
prefs.putInt("widx", selWeightIndex);
updateDisplay_Menu();
}
return;
}
if (state == ST_CAL_WAIT_EMPTY_TARE) {
performTareSilently();
state = ST_CAL_WAIT_PLACE_WEIGHT;
updateDisplay_Calibration();
return;
}
// normal silent tare
performTareSilently();
}
void onTareLong() { onTareShort(); }
void onCalShort() {
if (state == ST_IDLE) {
state = ST_MENU;
menuIndex = 0;
clearTempMessage();
updateDisplay_Menu();
return;
}
if (state == ST_MENU) {
menuIndex++;
if (menuIndex >= 3) {
state = ST_IDLE;
clearTempMessage();
showTempMessage("MENU -> KONIEC", 800);
} else {
updateDisplay_Menu();
}
return;
}
if (state == ST_CAL_SELECT_WEIGHT) {
state = ST_CAL_WAIT_EMPTY_TARE;
clearTempMessage();
updateDisplay_Calibration();
return;
} else if (state == ST_CAL_WAIT_PLACE_WEIGHT) {
// measure
delay(200);
long rawWith = scale.read_average(20);
long off = scale.get_offset();
long diff = rawWith - off;
int mass = weightOptions[selWeightIndex];
if (diff <= 0) {
showTempMessage("BLAD: ODCZYT<=0", 2000);
state = ST_IDLE;
return;
}
float newCal = (float)diff / (float)mass;
calFactor = newCal;
scale.set_scale(calFactor);
storedOffset = off;
saveConfig();
char buf[32]; snprintf(buf, 32, "KAL OK %0.3f raw/g", calFactor);
showTempMessage(buf, 2000);
state = ST_CAL_DONE;
updateDisplay_Calibration();
return;
} else if (state == ST_CAL_DONE) {
state = ST_IDLE;
showTempMessage("KONIEC KALIBR.", 1200);
return;
}
}
void onCalLong() {
// start calibration
state = ST_CAL_SELECT_WEIGHT;
clearTempMessage();
updateDisplay_Calibration();
}
void handleButtons() {
bool curT = digitalRead(btnT.pin);
bool curC = digitalRead(btnC.pin);
unsigned long now = millis();
// TARE
if (curT == LOW && btnT.lastState == HIGH) { btnT.downMs = now; btnT.longHandled = false; }
else if (curT == LOW && btnT.lastState == LOW) {
if (!btnT.longHandled && (now - btnT.downMs > LONG_PRESS_MS)) { btnT.longHandled = true; onTareLong(); }
} else if (curT == HIGH && btnT.lastState == LOW) {
unsigned long len = now - btnT.downMs;
if (len > DEBOUNCE_MS && len < LONG_PRESS_MS) onTareShort();
}
btnT.lastState = curT;
// CAL
if (curC == LOW && btnC.lastState == HIGH) { btnC.downMs = now; btnC.longHandled = false; }
else if (curC == LOW && btnC.lastState == LOW) {
if (!btnC.longHandled && (now - btnC.downMs > LONG_PRESS_MS)) { btnC.longHandled = true; onCalLong(); }
} else if (curC == HIGH && btnC.lastState == LOW) {
unsigned long len = now - btnC.downMs;
if (len > DEBOUNCE_MS && len < LONG_PRESS_MS) onCalShort();
}
btnC.lastState = curC;
}
// ---------- WEB server handlers ----------
String htmlHeader() {
String h = "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width, initial-scale=1'>";
h += "<title>Waga</title>";
h += "<style>body{font-family:Arial;text-align:center;padding:8px;} .meas{font-size:36px;margin:10px;} select,input{font-size:16px;padding:6px;margin:6px;} button{padding:8px 12px;margin:6px;font-size:16px;}</style>";
h += "</head><body>";
return h;
}
void handleRoot() {
String page = htmlHeader();
page += "<h2>WAGA - Ustawienia</h2>";
// current measurement header (JS will update)
page += "<div class='meas' id='meas'>--</div>";
page += "<div id='state' style='font-size:12px;color:#444;margin-bottom:8px;'></div>";
// form
page += "<form id='cfg' onsubmit='return false;'>";
// unit
page += "<div><label>Jednostka: <select id='unit' name='unit'>";
page += "<option value='0'" + String(unitSel==0 ? " selected" : "") + ">g</option>";
page += "<option value='1'" + String(unitSel==1 ? " selected" : "") + ">dkg</option>";
page += "<option value='2'" + String(unitSel==2 ? " selected" : "") + ">kg</option>";
page += "</select></label></div>";
// resolution
page += "<div><label>Dokladnosc: <select id='res' name='res'>";
page += "<option value='0'" + String(!highRes ? " selected" : "") + ">PELNE GRAMY</option>";
page += "<option value='1'" + String(highRes ? " selected" : "") + ">10/GRAM</option>";
page += "</select></label></div>";
// weight
page += "<div><label>Odwarznik kal.: <select id='widx' name='widx'>";
for (int i = 0; i < weightOptionsCount; ++i) {
page += "<option value='" + String(i) + "'" + String(selWeightIndex==i? " selected":"") + ">" + String(weightOptions[i]) + " g</option>";
}
page += "</select></label></div>";
page += "<div><button onclick='saveCfg()'>Zapisz</button><button onclick='doTare()' type='button'>TARA</button><button onclick='startCal()' type='button'>Start kalibracji</button></div>";
page += "</form>";
// status and script
page += "<hr><div style='font-size:12px;color:#666'>Podlacz sie do sieci WiFi: <b>waga</b> (haslo: <b>waga</b>) - jesli haslo krotsze niz 8 znakow, AP bedzie otwarte.</div>";
page += "<script>\n";
page += "function fetchStatus(){fetch('/status').then(r=>r.json()).then(j=>{document.getElementById('meas').innerText=j.weight + ' ' + j.unit; document.getElementById('state').innerText='Stan: '+j.state + ' | Waga: '+j.selWeight+'g'; // info\n// update select values if user hasn't changed\n}).catch(e=>{console.log(e);});}\n";
page += "function saveCfg(){ let u=document.getElementById('unit').value; let r=document.getElementById('res').value; let w=document.getElementById('widx').value; fetch('/save?unit='+u+'&res='+r+'&widx='+w).then(()=>alert('Ustawienia zapisane'))}\n";
page += "function doTare(){ fetch('/tare').then(()=>{setTimeout(fetchStatus,200); alert('TARA wykonana');}) }\n";
page += "function startCal(){ fetch('/startcal').then(()=>{alert('Start kalibracji - sprawdz LCD');}) }\n";
page += "setInterval(fetchStatus,1000); window.onload=fetchStatus;\n";
page += "</script></body></html>";
server.send(200, "text/html", page);
}
void handleSave() {
if (server.hasArg("unit")) {
unitSel = server.arg("unit").toInt();
prefs.putInt("unit", unitSel);
}
if (server.hasArg("res")) {
highRes = (server.arg("res").toInt() != 0);
prefs.putInt("res", highRes ? 1 : 0);
}
if (server.hasArg("widx")) {
selWeightIndex = server.arg("widx").toInt();
if (selWeightIndex < 0 || selWeightIndex >= weightOptionsCount) selWeightIndex = 0;
prefs.putInt("widx", selWeightIndex);
}
// redirect back
server.sendHeader("Location", "/");
server.send(303);
}
void handleTare() {
performTareSilently();
server.send(200, "text/plain", "OK");
}
void handleStartCal() {
state = ST_CAL_SELECT_WEIGHT;
clearTempMessage();
updateDisplay_Calibration();
server.send(200, "text/plain", "OK");
}
void handleStatus() {
// return JSON with weight and settings
char buf[64];
String unitStr = (unitSel==0?"g":(unitSel==1?"dkg":"kg"));
String stateStr;
switch (state) {
case ST_IDLE: stateStr = "IDLE"; break;
case ST_MENU: stateStr = "MENU"; break;
case ST_CAL_SELECT_WEIGHT: stateStr = "CAL_SELECT_WEIGHT"; break;
case ST_CAL_WAIT_EMPTY_TARE: stateStr = "CAL_WAIT_EMPTY_TARE"; break;
case ST_CAL_WAIT_PLACE_WEIGHT: stateStr = "CAL_WAIT_PLACE_WEIGHT"; break;
case ST_CAL_DONE: stateStr = "CAL_DONE"; break;
default: stateStr = "UNKNOWN"; break;
}
String weightVal = "--";
if (calFactor > 0.0f) {
float grams = scale.get_units(SAMPLE_COUNT);
double divisor = (unitSel == 0 ? 1.0 : (unitSel == 1 ? 10.0 : 1000.0));
double valueUnit = grams / divisor;
uint8_t decimals = fmtDecimals[unitSel][highRes ? 1 : 0];
char num[32];
formatMinimal(valueUnit, decimals, num, sizeof(num));
weightVal = String(num);
} else {
weightVal = String("BRAK");
}
String json = "{";
json += "\"weight\":\"" + weightVal + "\",";
json += "\"unit\":\"" + unitStr + "\",";
json += "\"state\":\"" + stateStr + "\",";
json += "\"selWeight\":" + String(weightOptions[selWeightIndex]) + ",";
json += "\"calFactor\":" + String(calFactor, 6) + ",";
json += "\"offset\":" + String(storedOffset);
json += "}";
server.send(200, "application/json", json);
}
// ---------- setup / loop ----------
void setup() {
Serial.begin(115200);
pinMode(BTN_TARE, INPUT_PULLUP);
pinMode(BTN_CAL, INPUT_PULLUP);
btnT.pin = BTN_TARE; btnT.lastState = digitalRead(btnT.pin);
btnC.pin = BTN_CAL; btnC.lastState = digitalRead(btnC.pin);
Wire.begin(I2C_SDA, I2C_SCL);
lcd.init();
lcd.backlight();
scale.begin(DOUT_PIN, SCK_PIN);
scale.set_scale(1.0f);
scale.read();
prefs.begin("scale_cfg", false);
calFactor = prefs.getFloat("cal", 0.0f);
storedOffset = prefs.getLong("off", 0);
unitSel = prefs.getInt("unit", 0);
highRes = prefs.getInt("res", 0) != 0;
selWeightIndex = prefs.getInt("widx", selWeightIndex); // default 6 if not stored
if (storedOffset != 0) scale.set_offset(storedOffset);
if (calFactor > 0.0f) scale.set_scale(calFactor);
// init lcd buffers to force redraw
for (int i = 0; i < 16; ++i) { lastLine1[i] = 0; lastLine2[i] = 0; }
lastLine1[16] = lastLine2[16] = '\0';
lcd.clear();
// start AP
const char *apSSID = "waga";
const char *apPass = "12345678";
bool apOk = false;
if (strlen(apPass) >= 8) {
apOk = WiFi.softAP(apSSID, apPass);
} else {
// password too short -> create open AP
apOk = WiFi.softAP(apSSID);
Serial.println("Password too short for WPA2 - AP started as OPEN");
}
if (!apOk) {
Serial.println("AP start FAILED");
} else {
IPAddress ip = WiFi.softAPIP();
Serial.print("AP started. SSID: ");
Serial.print(apSSID);
Serial.print(" IP: ");
Serial.println(ip.toString());
}
// start web server handlers
server.on("/", HTTP_GET, handleRoot);
server.on("/save", HTTP_GET, handleSave); // accept GET params
server.on("/save", HTTP_POST, handleSave); // accept POST too
server.on("/tare", HTTP_GET, handleTare);
server.on("/startcal", HTTP_GET, handleStartCal);
server.on("/status", HTTP_GET, handleStatus);
server.begin();
Serial.println("HTTP server started");
}
void loop() {
handleButtons();
server.handleClient();
// temporary message expire
if (tmpMsg[0] != ' ' && tmpMsgExpiry != 0 && millis() > tmpMsgExpiry) clearTempMessage();
static unsigned long lastRead = 0;
unsigned long now = millis();
if (now - lastRead >= READ_INTERVAL_MS) {
lastRead = now;
// update LCD according to state
if (state == ST_MENU) updateDisplay_Menu();
else if (state == ST_CAL_SELECT_WEIGHT || state == ST_CAL_WAIT_EMPTY_TARE || state == ST_CAL_WAIT_PLACE_WEIGHT || state == ST_CAL_DONE) updateDisplay_Calibration();
else updateDisplay_Normal();
}
// after calibration done -> back to idle with message
static unsigned long doneTs = 0;
if (state == ST_CAL_DONE) {
if (doneTs == 0) doneTs = millis();
if (millis() - doneTs > 2000) {
state = ST_IDLE;
doneTs = 0;
showTempMessage("KALIBRACJA ZAPISANA", 1200);
}
} else doneTs = 0;
delay(8);
}TARA
KALIBRACJA 2sek.
MENU