Created
January 27, 2026 11:40
-
-
Save eladn/b0bac59406ac969e2ce01561672e420e to your computer and use it in GitHub Desktop.
ESP32 Solar Pump Controller
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Solar Thermal Controller - Firmware V2.0 | |
| * Features: Diff Control, WiFi, Telegram Bot, Persistent Settings, LCD Menu Interface | |
| */ | |
| #include <Arduino.h> | |
| #include <Wire.h> | |
| #include <LiquidCrystal_I2C.h> | |
| #include <Preferences.h> | |
| #include <WiFi.h> | |
| #include <WiFiClientSecure.h> | |
| #include <UniversalTelegramBot.h> | |
| #include <ArduinoJson.h> | |
| #include <math.h> | |
| // ========================================== | |
| // 1. PIN DEFINITIONS | |
| // ========================================== | |
| #define PIN_RELAY 26 | |
| #define PIN_LED_RED 25 | |
| // Sensors | |
| #define PIN_NTC_COL 32 | |
| #define PIN_NTC_TANK 33 | |
| #define PIN_CURRENT 36 // VP pin (Input only) | |
| // Buttons | |
| #define BTN_MENU 19 | |
| #define BTN_UP 18 | |
| #define BTN_DOWN 5 | |
| #define BTN_ENTER 17 | |
| // ========================================== | |
| // 2. CONSTANTS & CONFIG | |
| // ========================================== | |
| #define NTC_R_FIXED 10000.0 | |
| #define NTC_BETA 3950.0 | |
| #define NTC_R25 10000.0 | |
| #define ADC_MAX 4095.0 | |
| #define V_REF 3.3 | |
| // ========================================== | |
| // 3. OBJECTS & GLOBAL VARIABLES | |
| // ========================================== | |
| LiquidCrystal_I2C lcd(0x27, 20, 4); | |
| Preferences prefs; | |
| WiFiClientSecure secured_client; | |
| UniversalTelegramBot* bot; // Pointer to init later | |
| // State Machine | |
| enum ScreenState { | |
| DASHBOARD, | |
| MENU_MAIN, | |
| EDIT_PARAM, // Editing numbers (Temps) | |
| EDIT_TEXT // Editing text (WiFi/Telegram) | |
| }; | |
| ScreenState currentScreen = DASHBOARD; | |
| int menuIndex = 0; | |
| int editParamIndex = 0; // Which numeric setting are we editing? | |
| int editTextIndex = 0; // Which text setting are we editing? | |
| // Text Editing Variables | |
| String editingBuffer = ""; | |
| int charCursor = 0; | |
| // Character set for text entry | |
| const char CHAR_MAP[] = " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_!@"; | |
| // ------------------------------------------ | |
| // DATA STRUCTURES | |
| // ------------------------------------------ | |
| struct SystemStatus { | |
| float tCol = 0.0; | |
| float tTank = 0.0; | |
| float tDiff = 0.0; | |
| float currentAmps = 0.0; | |
| float powerWatts = 0.0; | |
| bool pumpOn = false; | |
| String mode = "AUTO"; // AUTO, MAN-ON, MAN-OFF, ERR | |
| unsigned long lastPumpChange = 0; | |
| unsigned long lastInteraction = 0; | |
| bool backlightOn = true; | |
| bool wifiConnected = false; | |
| }; | |
| SystemStatus sys; | |
| struct Config { | |
| // Numeric Settings | |
| float deltaOn = 10.0; | |
| float deltaOff = 3.0; | |
| float maxTank = 75.0; | |
| int minRunTime = 60; | |
| // String Settings (WiFi/Telegram) | |
| String ssid = ""; | |
| String pass = ""; | |
| String botToken = ""; | |
| String chatID = ""; | |
| }; | |
| Config cfg; | |
| struct Stats { | |
| float maxColToday = -100; | |
| float energyToday = 0; | |
| unsigned long runtimeMs = 0; | |
| } stats; | |
| // Menus | |
| const char* MAIN_MENU_ITEMS[] = { | |
| "Man: Force ON/OFF", | |
| "Settings (Temps)", | |
| "Config WiFi", | |
| "Config Telegram", | |
| "System Stats", | |
| "Exit" | |
| }; | |
| const int MAIN_MENU_LEN = 6; | |
| // Timers | |
| unsigned long lastControlTime = 0; | |
| unsigned long lastLcdTime = 0; | |
| unsigned long lastTeleCheck = 0; | |
| // ========================================== | |
| // 4. BUTTON CLASS | |
| // ========================================== | |
| class Button { | |
| private: | |
| int pin; | |
| bool lastState; | |
| unsigned long lastDebounce; | |
| public: | |
| Button(int p) : pin(p) { pinMode(pin, INPUT_PULLUP); lastState = HIGH; } | |
| bool isPressed() { | |
| bool reading = digitalRead(pin); | |
| if (reading != lastState && (millis() - lastDebounce > 50)) { | |
| lastDebounce = millis(); | |
| if (reading == LOW) { | |
| lastState = reading; | |
| return true; | |
| } | |
| } | |
| lastState = reading; | |
| return false; | |
| } | |
| }; | |
| Button btnMenu(BTN_MENU); | |
| Button btnUp(BTN_UP); | |
| Button btnDown(BTN_DOWN); | |
| Button btnEnter(BTN_ENTER); | |
| // ========================================== | |
| // 5. STORAGE HELPER FUNCTIONS | |
| // ========================================== | |
| void loadConfig() { | |
| prefs.begin("solar_app", true); // Read-only mode = false, but here we want RW later | |
| cfg.deltaOn = prefs.getFloat("dOn", 10.0); | |
| cfg.deltaOff = prefs.getFloat("dOff", 3.0); | |
| cfg.maxTank = prefs.getFloat("maxT", 75.0); | |
| cfg.minRunTime = prefs.getInt("minT", 60); | |
| cfg.ssid = prefs.getString("ssid", ""); | |
| cfg.pass = prefs.getString("pass", ""); | |
| cfg.botToken = prefs.getString("tok", ""); | |
| cfg.chatID = prefs.getString("chat", ""); | |
| prefs.end(); | |
| } | |
| void saveParam(const char* key, float val) { | |
| prefs.begin("solar_app", false); | |
| prefs.putFloat(key, val); | |
| prefs.end(); | |
| } | |
| void saveString(const char* key, String val) { | |
| prefs.begin("solar_app", false); | |
| prefs.putString(key, val); | |
| prefs.end(); | |
| } | |
| // ========================================== | |
| // 6. SENSORS & LOGIC | |
| // ========================================== | |
| float readNTC(int pin) { | |
| long sum = 0; | |
| for(int i=0; i<20; i++) { sum += analogRead(pin); delay(2); } | |
| float avg = sum / 20.0; | |
| if(avg <= 0 || avg >= ADC_MAX) return -99.0; | |
| float r_ntc = (avg * NTC_R_FIXED) / (ADC_MAX - avg); | |
| float tempK = 1.0 / ( (1.0/298.15) + (1.0/NTC_BETA) * log(r_ntc/NTC_R25) ); | |
| return tempK - 273.15; | |
| } | |
| float readCurrent() { | |
| // Simple Peak-Peak logic | |
| int maxVal = 0, minVal = 4095; | |
| unsigned long start = millis(); | |
| while(millis() - start < 40) { | |
| int val = analogRead(PIN_CURRENT); | |
| if(val > maxVal) maxVal = val; | |
| if(val < minVal) minVal = val; | |
| } | |
| float pp = ((maxVal - minVal) * V_REF) / ADC_MAX; | |
| float amps = (pp * 0.707) * 5.0; // Calibration factor 5.0 (Adjust as needed) | |
| return (amps < 0.1) ? 0.0 : amps; | |
| } | |
| void controlLoop() { | |
| // 1. Read | |
| float tc = readNTC(PIN_NTC_COL); | |
| float tt = readNTC(PIN_NTC_TANK); | |
| sys.tCol = tc; | |
| sys.tTank = tt; | |
| sys.tDiff = tc - tt; | |
| if (sys.pumpOn) { | |
| sys.currentAmps = readCurrent(); | |
| sys.powerWatts = sys.currentAmps * 230.0; | |
| } else { | |
| sys.currentAmps = 0; sys.powerWatts = 0; | |
| } | |
| // 2. Safety | |
| if (tc < -50 || tt < -50) { | |
| sys.mode = "ERR:SENS"; | |
| digitalWrite(PIN_RELAY, LOW); | |
| sys.pumpOn = false; | |
| return; | |
| } | |
| // 3. Logic | |
| // Manual Override? | |
| if (sys.mode == "MAN-ON") { | |
| digitalWrite(PIN_RELAY, HIGH); sys.pumpOn = true; return; | |
| } | |
| if (sys.mode == "MAN-OFF") { | |
| digitalWrite(PIN_RELAY, LOW); sys.pumpOn = false; return; | |
| } | |
| // Auto Logic | |
| sys.mode = "AUTO"; | |
| unsigned long now = millis(); | |
| unsigned long duration = (now - sys.lastPumpChange) / 1000; | |
| if (!sys.pumpOn) { | |
| // Start condition | |
| if (sys.tDiff >= cfg.deltaOn && sys.tTank < cfg.maxTank && duration > cfg.minRunTime) { | |
| digitalWrite(PIN_RELAY, HIGH); | |
| digitalWrite(PIN_LED_RED, HIGH); | |
| sys.pumpOn = true; | |
| sys.lastPumpChange = now; | |
| } | |
| } else { | |
| // Stop condition | |
| if ((sys.tDiff <= cfg.deltaOff || sys.tTank >= cfg.maxTank) && duration > cfg.minRunTime) { | |
| digitalWrite(PIN_RELAY, LOW); | |
| digitalWrite(PIN_LED_RED, LOW); | |
| sys.pumpOn = false; | |
| sys.lastPumpChange = now; | |
| } | |
| } | |
| } | |
| // ========================================== | |
| // 7. TELEGRAM & WIFI | |
| // ========================================== | |
| void connectWiFi() { | |
| if (cfg.ssid == "") return; // No config | |
| if (WiFi.status() != WL_CONNECTED) { | |
| WiFi.begin(cfg.ssid.c_str(), cfg.pass.c_str()); | |
| // Non-blocking check happens in loop | |
| } | |
| } | |
| void handleTelegram() { | |
| if (WiFi.status() != WL_CONNECTED || cfg.botToken == "") return; | |
| int numNewMsg = bot->getUpdates(bot->last_message_received + 1); | |
| while(numNewMsg) { | |
| for (int i=0; i<numNewMsg; i++) { | |
| String chat_id = String(bot->messages[i].chat_id); | |
| String text = bot->messages[i].text; | |
| if (chat_id != cfg.chatID) { | |
| bot->sendMessage(chat_id, "Unauthorized", ""); | |
| continue; | |
| } | |
| if (text == "/status") { | |
| String m = "Stats:\nCol: " + String(sys.tCol,1) + "\nTank: " + String(sys.tTank,1); | |
| m += "\nPump: " + String(sys.pumpOn?"ON":"OFF"); | |
| m += "\nMode: " + sys.mode; | |
| bot->sendMessage(chat_id, m, ""); | |
| } | |
| else if (text == "/on") { sys.mode = "MAN-ON"; bot->sendMessage(chat_id, "Forced ON", ""); } | |
| else if (text == "/off") { sys.mode = "MAN-OFF"; bot->sendMessage(chat_id, "Forced OFF", ""); } | |
| else if (text == "/auto") { sys.mode = "AUTO"; bot->sendMessage(chat_id, "Set AUTO", ""); } | |
| } | |
| numNewMsg = bot->getUpdates(bot->last_message_received + 1); | |
| } | |
| } | |
| // ========================================== | |
| // 8. LCD UI FUNCTIONS | |
| // ========================================== | |
| void renderDashboard() { | |
| lcd.setCursor(0,0); lcd.printf("C:%04.1f T:%04.1f ", sys.tCol, sys.tTank); | |
| lcd.setCursor(0,1); lcd.printf("Df:%04.1f PMP:%s ", sys.tDiff, sys.pumpOn?"ON ":"OFF"); | |
| lcd.setCursor(0,2); lcd.printf("I:%04.2fA %s ", sys.currentAmps, sys.mode.c_str()); | |
| lcd.setCursor(0,3); lcd.printf("WiFi:%s ", WiFi.status()==WL_CONNECTED?"OK":"--"); | |
| } | |
| void renderMenu() { | |
| lcd.setCursor(0,0); lcd.print("== MENU =="); | |
| // Simple scroll view | |
| int start = (menuIndex > 2) ? menuIndex - 2 : 0; | |
| for (int i=0; i<3; i++) { | |
| lcd.setCursor(0, i+1); | |
| if (start+i < MAIN_MENU_LEN) { | |
| if (start+i == menuIndex) lcd.print(">"); | |
| else lcd.print(" "); | |
| lcd.print(MAIN_MENU_ITEMS[start+i]); | |
| } else { | |
| lcd.print(" "); | |
| } | |
| } | |
| } | |
| // Helper to draw text editor | |
| void renderTextEditor(String title) { | |
| lcd.setCursor(0,0); lcd.print(title); | |
| // Show the buffer | |
| lcd.setCursor(0,1); | |
| int startPos = (charCursor > 18) ? charCursor - 18 : 0; | |
| String displayStr = editingBuffer.substring(startPos, startPos + 19); | |
| lcd.print(displayStr); | |
| // Blinking cursor logic | |
| lcd.setCursor(charCursor - startPos, 1); | |
| lcd.blink(); | |
| lcd.setCursor(0,2); lcd.print("^/v:Char Ent:Next"); | |
| lcd.setCursor(0,3); lcd.print("Hold ENT: Save"); | |
| } | |
| void handleInput() { | |
| if (btnMenu.isPressed()) { | |
| sys.lastInteraction = millis(); | |
| lcd.backlight(); sys.backlightOn = true; | |
| // Toggle Menu | |
| if (currentScreen == DASHBOARD) { currentScreen = MENU_MAIN; menuIndex = 0; } | |
| else if (currentScreen != DASHBOARD) { | |
| currentScreen = DASHBOARD; | |
| lcd.noBlink(); // Ensure blink is off | |
| } | |
| return; | |
| } | |
| // --- DASHBOARD --- | |
| if (currentScreen == DASHBOARD) return; | |
| // --- MAIN MENU --- | |
| if (currentScreen == MENU_MAIN) { | |
| if (btnUp.isPressed()) menuIndex = max(0, menuIndex - 1); | |
| if (btnDown.isPressed()) menuIndex = min(MAIN_MENU_LEN - 1, menuIndex + 1); | |
| if (btnEnter.isPressed()) { | |
| lcd.clear(); | |
| switch(menuIndex) { | |
| case 0: // Manual Override Toggle | |
| if(sys.mode == "AUTO") sys.mode = "MAN-ON"; | |
| else if(sys.mode == "MAN-ON") sys.mode = "MAN-OFF"; | |
| else sys.mode = "AUTO"; | |
| currentScreen = DASHBOARD; | |
| break; | |
| case 1: currentScreen = EDIT_PARAM; editParamIndex = 0; break; | |
| case 2: currentScreen = EDIT_TEXT; editTextIndex = 0; editingBuffer = cfg.ssid; charCursor = 0; break; // SSID | |
| case 3: currentScreen = EDIT_TEXT; editTextIndex = 2; editingBuffer = cfg.botToken; charCursor = 0; break; // Token | |
| case 4: /* Show stats screen logic here - simplified for brevity */ currentScreen = DASHBOARD; break; | |
| case 5: currentScreen = DASHBOARD; break; | |
| } | |
| } | |
| } | |
| // --- EDIT NUMERIC (Simple) --- | |
| else if (currentScreen == EDIT_PARAM) { | |
| lcd.setCursor(0,0); lcd.print("Setting: "); | |
| if(editParamIndex==0) { lcd.print("Delta ON "); lcd.setCursor(0,1); lcd.print(cfg.deltaOn); } | |
| if(editParamIndex==1) { lcd.print("Delta OFF"); lcd.setCursor(0,1); lcd.print(cfg.deltaOff); } | |
| if (btnUp.isPressed()) { | |
| if(editParamIndex==0) cfg.deltaOn += 0.5; | |
| if(editParamIndex==1) cfg.deltaOff += 0.5; | |
| } | |
| if (btnDown.isPressed()) { | |
| if(editParamIndex==0) cfg.deltaOn -= 0.5; | |
| if(editParamIndex==1) cfg.deltaOff -= 0.5; | |
| } | |
| if (btnEnter.isPressed()) { | |
| saveParam(editParamIndex==0?"dOn":"dOff", editParamIndex==0?cfg.deltaOn:cfg.deltaOff); | |
| editParamIndex++; | |
| if(editParamIndex > 1) currentScreen = MENU_MAIN; // Done | |
| } | |
| } | |
| // --- EDIT TEXT (Complex) --- | |
| else if (currentScreen == EDIT_TEXT) { | |
| // editTextIndex: 0=SSID, 1=PASS, 2=TOKEN, 3=CHATID | |
| String titles[] = {"WiFi SSID:", "WiFi Pass:", "Bot Token:", "Chat ID:"}; | |
| renderTextEditor(titles[editTextIndex]); | |
| // Character cycling | |
| static int charMapIdx = 0; | |
| // Hold Enter to Save | |
| if (digitalRead(BTN_ENTER) == LOW) { | |
| unsigned long holdStart = millis(); | |
| while(digitalRead(BTN_ENTER) == LOW) { | |
| if (millis() - holdStart > 1500) { // Held for 1.5s | |
| lcd.noBlink(); | |
| lcd.clear(); lcd.print("SAVED!"); delay(1000); | |
| // Save based on index | |
| if(editTextIndex==0) { saveString("ssid", editingBuffer); editTextIndex=1; editingBuffer=cfg.pass; } // Go to Pass | |
| else if(editTextIndex==1) { saveString("pass", editingBuffer); currentScreen=MENU_MAIN; ESP.restart(); } // Restart to apply WiFi | |
| else if(editTextIndex==2) { saveString("tok", editingBuffer); editTextIndex=3; editingBuffer=cfg.chatID; } // Go to ChatID | |
| else if(editTextIndex==3) { saveString("chat", editingBuffer); currentScreen=MENU_MAIN; } // Done | |
| charCursor = 0; charMapIdx = 0; | |
| return; // Break out | |
| } | |
| } | |
| // If short press, confirm character | |
| charCursor++; | |
| if(charCursor >= editingBuffer.length()) editingBuffer += " "; // Expand buffer | |
| } | |
| if (btnUp.isPressed()) { | |
| charMapIdx = (charMapIdx + 1) % strlen(CHAR_MAP); | |
| editingBuffer[charCursor] = CHAR_MAP[charMapIdx]; | |
| } | |
| if (btnDown.isPressed()) { | |
| charMapIdx = (charMapIdx - 1); | |
| if(charMapIdx < 0) charMapIdx = strlen(CHAR_MAP) - 1; | |
| editingBuffer[charCursor] = CHAR_MAP[charMapIdx]; | |
| } | |
| } | |
| } | |
| // ========================================== | |
| // 9. MAIN SETUP & LOOP | |
| // ========================================== | |
| void setup() { | |
| Serial.begin(115200); | |
| // Init Hardware | |
| pinMode(PIN_RELAY, OUTPUT); | |
| pinMode(PIN_LED_RED, OUTPUT); | |
| digitalWrite(PIN_RELAY, LOW); | |
| // Init LCD | |
| lcd.init(); | |
| lcd.backlight(); | |
| lcd.setCursor(0,0); lcd.print("SOLAR CONTROLLER v2"); | |
| // Load Settings | |
| loadConfig(); | |
| // Init WiFi & Bot | |
| secured_client.setCACert(TELEGRAM_CERTIFICATE_ROOT); | |
| bot = new UniversalTelegramBot(cfg.botToken, secured_client); | |
| if (cfg.ssid != "") { | |
| lcd.setCursor(0,1); lcd.print("Connecting WiFi..."); | |
| WiFi.begin(cfg.ssid.c_str(), cfg.pass.c_str()); | |
| } | |
| delay(2000); | |
| lcd.clear(); | |
| } | |
| void loop() { | |
| unsigned long now = millis(); | |
| // 1. Control Logic (Every 200ms) | |
| if (now - lastControlTime > 200) { | |
| controlLoop(); | |
| lastControlTime = now; | |
| } | |
| // 2. User Input | |
| handleInput(); | |
| // 3. LCD Update (Every 300ms, only if not blink-editing) | |
| if (now - lastLcdTime > 300 && currentScreen != EDIT_TEXT) { | |
| if (currentScreen == DASHBOARD) renderDashboard(); | |
| else if (currentScreen == MENU_MAIN) renderMenu(); | |
| // Backlight Timeout | |
| if (sys.backlightOn && (now - sys.lastInteraction > 60000)) { | |
| lcd.noBacklight(); sys.backlightOn = false; | |
| } | |
| lastLcdTime = now; | |
| } | |
| // 4. Telegram Check (Every 3s, if WiFi connected) | |
| if (now - lastTeleCheck > 3000) { | |
| if (WiFi.status() == WL_CONNECTED) { | |
| handleTelegram(); | |
| } | |
| lastTeleCheck = now; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment