Battery voltage and current draw monitor
For this project the goal was to keep track of backup batteries during float and active states. This helps to ensure the trickle (solar) charger is still functioning, and when in a power outage, how much current is being drawn from the system and how much capacity is left. The system can be shut down remotely if is appears the batteries are out. An email alert is also sent when voltage goes below 11 volts.
Wiring:
parts:
- Voltage divider - 10 kohm / 2.5 kohm resistors will get us down from 15 to a safe readable 3 volts - https://www.digikey.ca/en/resources/conversion-calculators/conversion-calculator-voltage-divider
- SCT-013 current sensor - https://www.aliexpress.com/item/32708887594.html
- ADS1115 high precision multiplexer - https://www.aliexpress.com/item/32817162654.html
- L2C display - https://www.aliexpress.com/item/32649621944.html
- ESP32 (microcontroller with wifi) - https://www.aliexpress.com/item/1005006246777139.html?spm=a2g0o.order_list.order_list_main.58.21ef1802mrPQC7
- 3d printed case - https://www.thingiverse.com/thing:6684072
The current sensor is basically just a transformer coil. It is much easier than using a shunt.
To calibrate I measured real-time reading compared to an outlet wall meter. Needed some minor adjustments but overall pretty accurate out-of-the-box |
Online view of realtime data:
code:
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_ADS1X15.h>
#include <LiquidCrystal_I2C.h>
#include <EEPROM.h>
// last updated July 1, 2024
//////////////////////////////
// EDIT THIS SECTION
int activeConnection = 1;
// cottage 1
const String ssid1 = "rewa";
const String password1 = "gfggf";
// cottage 2
const String ssid2 = "fdsa";
const String password2 = "rreter";
const String voltateUrl = "http://batterymonitor.uytr.ca/set_current_voltage.php?data=";
// end edit section
////////////////////
#define EEPROM_SIZE 8
int eepromActiveConnectionAddress = 0;
int eepromPingsAddress = 2;
float totalServerPings = 0;
int eepromFailedPingsAddress = 1;
float totalServerPingFails = 0;
int eepromActiveConnection = 1;
String prevWattagePerHourText = "";
String prevWattageVoltageText = "";
String prevConnectivityText1 = "";
String prevConnectivityText2 = "";
const int potPin = 34;
const int buttonPin = 14;
int buttonState = 0;
int displayMode = 1; // 1: watts, watts per hours, voltage 2: internet
int ACDCVoltage = 120;
const float criticalVoltage = 11.8; // this is considered the critical point for deep cycle sla batteries
const float deadBatteryVoltage = 9; // set to 9
const float potOffset = 880; // offset accounts for base reading on pot value - should be zero in theory but in reality it is not
const float voltageMultiplier = 0.025; // adjust for non-linear increase in pot value vs actual voltage
const float voltageDividerRatio = 4; // 10K / 2.5k voltage divider
const int pulseRate = 1000; // loop runs once per second
const int serverSendInterval = 15; // 15 minutes between sending a voltage update to the server
const int samplesPerReading = 60 * serverSendInterval; // every x minutes send a sample to the server
float averagePotValue = 0;
float potValue = 0;
float accumulatedPotValues = 0;
int voltageLoopCount = 0;
int lcdBacklightOnCounter = 0;
float accumulatedAmps = 0;
float averageCurrentValue = 0;
float averageWattage = 0;
float wattsPerHour = 0;
float ampHoursPerDay = 0;
long secondsOfHour = 0;
const int secondsPerHour = 3600; // set to 60 for debugging
int houryAmpsArrayCurrentIndex = 0;
int dailyAmpsArrayCurrentIndex = 0;
float houryAmpsArray[secondsPerHour];
const int hoursPerDay = 24;
float dailyAmpsArray[hoursPerDay];
float voltage;
bool connectingToWifi = false;
bool wifiConnected = false;
bool wifiPaused = false;
int wifiPausedTick = 0;
bool wifiSleeping = false;
bool debug = false; // when true server does not update
bool serverFailed = false;
int wifiConnectionAttempts = 0;
HTTPClient http;
Adafruit_ADS1115 ads;
LiquidCrystal_I2C lcd_i2c(0x27, 16, 2);
const float FACTOR = 20; //20A/1V from the CT
const float hallSensorFudge = 0.0000472; // fudge for inaccuracy in the hall sensor
void setup() {
// We sometimes run into brownouts due to main hydro line voltage drops. For this situation set BOD to 3 volts
// WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); //disable brownout detector
WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0x03); // 3 volts
//WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0x04); // set brownout detector to 3.2 volts
// WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0x07); // Set 3.3V as BOD level
Serial.begin(115200); // this needs to match the value in the serial monitor
//Init EEPROM
EEPROM.begin(EEPROM_SIZE);
pinMode(buttonPin, INPUT);
for (int i = 0; i < secondsPerHour; i++) {
houryAmpsArray[i] = -1;
}
for (int i = 0; i < hoursPerDay; i++) {
dailyAmpsArray[i] = -1;
}
delay(2000);
EEPROM.begin(EEPROM_SIZE);
activeConnection = EEPROM.read(eepromActiveConnectionAddress);
Serial.print("eepromActiveConnection :");
Serial.println(activeConnection);
if (isnan(activeConnection)) {
activeConnection = 1;
}
if (activeConnection == 0) {
activeConnection = 1;
}
if (activeConnection > 2) {
activeConnection = 2;
}
Serial.print("activeConnection: ");
Serial.println(activeConnection);
if (!connectToWiFi()) {
delay(2000);
WiFi.disconnect();
delay(1000);
if (activeConnection == 1) {
activeConnection = 2;
} else {
activeConnection = 1;
}
connectToWiFi();
}
// in case we did a reboot allow remote server a moment
delay(5000);
// if wifi connect failed again just reboot
if (WiFi.status() != WL_CONNECTED) {
ESP.restart();
}
if (!ads.begin()) {
Serial.println("Failed to initialize ADS.");
while (1)
;
}
ads.setGain(GAIN_FOUR);
lcd_i2c.init();
lcd_i2c.backlight();
}
void loop() {
secondsOfHour++;
// after 24 hours reset this integer
if (secondsOfHour > 86400) {
secondsOfHour = 1;
// Serial.println("restarting :");
ESP.restart();
}
if (lcdBacklightOnCounter == 0) {
//lcd_i2c.noBacklight();
lcdBacklightOnCounter++;
displayMode = 1;
} else {
// if lcb backlight counter is over zero it is active so increment
lcdBacklightOnCounter++;
Serial.print("lcdBacklightOnCounter: ");
Serial.println(lcdBacklightOnCounter);
// turn off backlight after 5 mintes of inactivity
if (lcdBacklightOnCounter % 150 == 0) {
Serial.println("backlight off");
displayMode = 2; // ensure when clicking for light we see temp again
lcd_i2c.noBacklight();
lcdBacklightOnCounter = 1;
}
}
buttonState = digitalRead(buttonPin);
Serial.println("buttonState: ");
Serial.println(buttonState);
if (buttonState == LOW) {
lcd_i2c.clear();
lcd_i2c.backlight();
lcdBacklightOnCounter = 1;
displayMode++;
if (displayMode > 2) {
displayMode = 1;
}
}
////////////////////////////
// CURRENT AND VOLTAGE
// CURRENT
float amps = getAmps();
accumulatedAmps += amps;
cycleHourlyAmpsArray(amps);
float ampsPerHour = getAverageAmps(houryAmpsArray, secondsPerHour);
// to get average wattage get the last 10 reading divided by 10
float wattsAverage = getLastXWattReadings(5) * ACDCVoltage;
float wattsPerHour = ampsPerHour * ACDCVoltage;
float ampHoursPerDay = getAverageAmps(dailyAmpsArray, hoursPerDay);
if (secondsOfHour % secondsPerHour == 0) {
cycleDailyAmpsArray(ampsPerHour);
}
float dailyAmpHours = getDailyAmpHours();
float wattagePerDay = dailyAmpHours * ACDCVoltage;
// VOLTAGE
potValue = analogRead(potPin);
accumulatedPotValues += potValue;
Serial.print("pot value:");
Serial.println(potValue);
voltage = (potValue / (potOffset + (potValue * voltageMultiplier)) * voltageDividerRatio);
Serial.print("volts");
Serial.println(voltage);
String wattsAverageString = String(wattsAverage, 0);
wattsAverageString.trim();
String wattsPerHourString = String(wattsPerHour, 0);
wattsPerHourString.trim();
String wattsPerDayString = String(wattagePerDay, 0);
wattsPerDayString.trim();
String wattagePerHourText = wattsPerHourString + "WH " + wattsPerDayString + "DWH ";
String wattageVoltageText = wattsAverageString + "W BV:" + String(voltage, 1);
// keep the noise down
if (voltageLoopCount % 10 == 0) {
averagePotValue = accumulatedPotValues / voltageLoopCount;
voltage = (averagePotValue / (potOffset + (averagePotValue * voltageMultiplier)) * voltageDividerRatio);
float currentVoltage = (potValue / (potOffset + (potValue * voltageMultiplier)) * voltageDividerRatio);
}
averagePotValue = accumulatedPotValues / voltageLoopCount;
voltage = (averagePotValue / (potOffset + (averagePotValue * voltageMultiplier)) * voltageDividerRatio);
averageWattage = (accumulatedAmps / voltageLoopCount) * ACDCVoltage;
/////////////////////
// SERVER RELAY
// if voltage is below 9 the batteries are basically dead!
if (!debug && (voltageLoopCount >= samplesPerReading && voltage >= deadBatteryVoltage) || serverFailed) {
accumulatedPotValues = 0;
accumulatedAmps = 0;
voltageLoopCount = 0;
setWifiSleepMode(false);
String recordedAverageVoltage = String(voltage, 2);
String recordedAverageWattage = String(averageWattage, 0);
recordedAverageVoltage.trim();
recordedAverageWattage.trim();
http.begin(voltateUrl + recordedAverageVoltage + "," + recordedAverageWattage);
int httpCode = http.GET();
if (httpCode > 0) {
String payload = http.getString();
Serial.println("HTTP Response: " + payload);
recordPingSucces();
} else {
Serial.println("HTTP Request failed with error code: " + String(httpCode));
recordPingFailure();
}
http.end();
setWifiSleepMode(true);
}
voltageLoopCount++;
////////////////////////////
// LCD DISPLAY
switch (displayMode) {
case 1:
wattagePerHourText.trim();
wattageVoltageText.trim();
if (prevWattagePerHourText != wattagePerHourText || prevWattageVoltageText != wattageVoltageText) {
lcd_i2c.clear();
}
prevWattagePerHourText = wattagePerHourText;
prevWattageVoltageText = wattageVoltageText;
lcd_i2c.setCursor(0, 0);
lcd_i2c.print(wattagePerHourText);
lcd_i2c.setCursor(0, 1);
lcd_i2c.print(wattageVoltageText);
break;
case 2:
String connectivityText1 = "";
if (wifiConnected) {
connectivityText1 += "WF ON";
}
if (wifiConnected && !serverFailed) {
connectivityText1 += " SERVER ON";
}
String serverPings = String(totalServerPings, 0);
serverPings.trim();
String serverPingFails = String(totalServerPingFails, 0);
serverPingFails.trim();
String connectivityText2 = serverPings + "/" + serverPingFails + " PINGS";
connectivityText1.trim();
connectivityText2.trim();
if (prevConnectivityText1 != connectivityText1 || prevConnectivityText2 != connectivityText2) {
lcd_i2c.clear();
}
prevConnectivityText1 = connectivityText1;
prevConnectivityText2 = connectivityText2;
lcd_i2c.setCursor(0, 0);
lcd_i2c.print(connectivityText1);
lcd_i2c.setCursor(0, 1);
lcd_i2c.print(connectivityText2);
break;
}
delay(pulseRate);
}
bool connectToWiFi() {
if (connectingToWifi || wifiConnected) {
return true;
}
connectingToWifi = true;
String activeSsid = "";
String activePassword = "";
if (activeConnection == 1) {
activeSsid = ssid1;
activePassword = password1;
} else if (activeConnection == 2) {
activeSsid = ssid2;
activePassword = password2;
}
Serial.print("Connecting to WiFi: ");
Serial.println(activeSsid);
WiFi.begin(activeSsid, activePassword);
while (WiFi.status() != WL_CONNECTED && wifiConnectionAttempts < 20) {
delay(500);
Serial.print(".");
wifiConnectionAttempts++;
}
wifiConnectionAttempts = 0;
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nConnected to WiFi");
Serial.print("IP Address: ");
Serial.println(WiFi.localIP());
EEPROM.write(eepromActiveConnectionAddress, activeConnection);
EEPROM.commit();
Serial.print("set activeConnection to : ");
Serial.print(activeConnection);
wifiConnected = true;
connectingToWifi = false;
return true;
} else {
Serial.print("Connection to ");
Serial.print(activeSsid);
Serial.println(" failed. Trying alternative");
wifiConnected = false;
connectingToWifi = false;
return false;
}
}
/**
* set wifi sleep mode between data relays to conserve energy
* @param sleepMode - if true set wifi card to sleep to conserve energy
*/
void setWifiSleepMode(bool sleepMode) {
wifiSleeping = sleepMode;
if (sleepMode) {
WiFi.disconnect();
WiFi.setSleep(true);
wifiConnected = false;
delay(1000);
Serial.print("sleep wifi status: ");
Serial.println(wl_status_to_string(WiFi.status()));
} else {
WiFi.setSleep(false);
WiFi.reconnect();
delay(2000);
Serial.print("awaken wifi status: ");
Serial.println(wl_status_to_string(WiFi.status()));
// Check if the connection is still active. if not trigger wait for it to come back online
if (WiFi.status() != WL_CONNECTED && !wifiPaused) {
Serial.println("Connection lost. Attempting to reconnect in 1 minute ...");
WiFi.disconnect();
wifiPaused = true;
wifiConnected = false;
connectToWiFi();
}
}
}
/**
* record server ping success in long term memory
*/
void recordPingSucces() {
totalServerPings++;
EEPROM.begin(EEPROM_SIZE);
EEPROM.writeFloat(eepromPingsAddress, totalServerPings);
EEPROM.commit();
EEPROM.end();
wifiConnected = true;
serverFailed = false;
}
/**
* record server ping fails in long term memory
*/
void recordPingFailure() {
totalServerPingFails++;
EEPROM.begin(EEPROM_SIZE);
EEPROM.writeFloat(eepromFailedPingsAddress, totalServerPingFails);
EEPROM.commit();
EEPROM.end();
wifiConnected = false;
serverFailed = true;
}
/**
* ESP32 wifi card statuses
* @param status
* @return string
*/
String wl_status_to_string(wl_status_t status) {
String response = "";
switch (status) {
case WL_NO_SHIELD:
response = "WL_NO_SHIELD";
break;
case WL_IDLE_STATUS:
response = "WL_IDLE_STATUS";
break;
case WL_NO_SSID_AVAIL:
response = "WL_NO_SSID_AVAIL";
break;
case WL_SCAN_COMPLETED:
response = "WL_SCAN_COMPLETED";
break;
case WL_CONNECTED:
response = "WL_CONNECTED";
break;
case WL_CONNECT_FAILED:
response = "WL_CONNECT_FAILED";
break;
case WL_CONNECTION_LOST:
response = "WL_CONNECTION_LOST";
break;
case WL_DISCONNECTED:
response = "WL_DISCONNECTED";
break;
}
return response;
}
/**
* Get the current in amps coming from the hall sensor
* @return float
*/
float getAmps() {
float hallSensorVoltage;
float current;
float sum = 0;
long time_check = millis();
int counter = 0;
while (millis() - time_check < 1000) {
hallSensorVoltage = ads.readADC_Differential_0_1() * hallSensorFudge; // get voltage from hall sensor with fugdge
current = hallSensorVoltage * FACTOR; // 1 volt = 20 amps with current sensor
sum += sq(current);
counter = counter + 1;
}
current = sqrt(sum / counter);
return (current);
}
/**
* read the accumulated amps divided by readings
*/
float getAverageAmps(float array[], int size) {
float accumulatedValues = 0;
int ampCounts = 0;
for (int i = 0; i < size - 1; i++) {
if (array[i] >= 0) {
ampCounts++;
accumulatedValues += array[i];
}
}
return accumulatedValues / ampCounts;
}
/**
* read the total amp over a 24 hour periods
*/
float getDailyAmpHours() {
float accumulatedValues = 0;
for (int i = 0; i < hoursPerDay - 1; i++) {
if (dailyAmpsArray[i] >= 0) {
accumulatedValues += dailyAmpsArray[i];
}
}
return accumulatedValues;
}
/**
* get average wattage from samples
* @param int sampleCount
* @return float
*/
float getLastXWattReadings(int sampleCount) {
float accumulatedValues = 0;
int countedValues = 0;
for (int i = secondsPerHour; i > 0; i--) {
if (houryAmpsArray[i - 1] >= 0) {
accumulatedValues += houryAmpsArray[i - 1];
countedValues++;
}
if (countedValues >= sampleCount) {
break;
}
}
return accumulatedValues / sampleCount;
}
/**
* remove first item from array, shift all value to left and add new value to end.
*/
void cycleHourlyAmpsArray(float newValue) {
// this means we the array if full so we can begin shifting
if (houryAmpsArray[secondsPerHour - 1] >= 0) {
for (int i = 0; i < secondsPerHour - 1; i++) {
houryAmpsArray[i] = houryAmpsArray[i + 1];
}
houryAmpsArray[secondsPerHour - 1] = newValue;
} else {
// allow the array to initialize with real values
houryAmpsArray[houryAmpsArrayCurrentIndex] = newValue;
houryAmpsArrayCurrentIndex++;
if (houryAmpsArrayCurrentIndex > secondsPerHour) {
houryAmpsArrayCurrentIndex = secondsPerHour - 1;
}
}
}
/**
* remove first item from array, shift all value to left and add new value to end.
*/
void cycleDailyAmpsArray(float newValue) {
// this means we the array if full so we can begin shifting
if (dailyAmpsArray[hoursPerDay - 1] >= 0) {
for (int i = 0; i < hoursPerDay - 1; i++) {
dailyAmpsArray[i] = dailyAmpsArray[i + 1];
}
dailyAmpsArray[hoursPerDay - 1] = newValue;
} else {
// allow the array to initialize with real values
dailyAmpsArray[dailyAmpsArrayCurrentIndex] = newValue;
dailyAmpsArrayCurrentIndex++;
if (dailyAmpsArrayCurrentIndex > hoursPerDay) {
dailyAmpsArrayCurrentIndex = hoursPerDay - 1;
}
}
}