Skip to content

Instantly share code, notes, and snippets.

@eladn
Created January 27, 2026 11:40
Show Gist options
  • Select an option

  • Save eladn/b0bac59406ac969e2ce01561672e420e to your computer and use it in GitHub Desktop.

Select an option

Save eladn/b0bac59406ac969e2ce01561672e420e to your computer and use it in GitHub Desktop.
ESP32 Solar Pump Controller
/**
* 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