// reference for configTime https://lastminuteengineers.com/esp32-ntp-server-date-time-tutorial/?utm_content=cmp-true
// might want to just work out how to do NTP myself on ESP32-C3 instead to avoid
// too many code changes. Configtime example: https://wokwi.com/projects/321525495180034642
// TODO - convert all below to a C3 microcontroller at some point
// reference for UDP: http://www.iotsharing.com/2017/06/how-to-use-udpip-with-arduino-esp32.html
// ===================================
// = HAS - Heating Automation System =
// ===================================
// =========== Version 0.3 ===========
// --- defines ---
// allows easier editing of the pertinent times
#define TIME_TO_SECS(hours, mins, secs) ((hours) * 3600UL + (mins) * 60 + (secs))
// --- includes ---
#include <ESP32Servo.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <ctime>
// --- notes ---
// 86400000 ms/d (we'll want to % on this
//4294967295 max ulong
//4233600000 reset day (midnight)
// --- consts ---
const int ON = 1;
const int OFF = 0;
const int CH_MAX_TEMP = 18; // Celcius (of course!)
const int CH_MIN_TEMP = 15;
const int CH_CALIBRATION = 0; // !!DISABLED!! how far out (man) we think our sensor is (e.g. +3°C)
const float REF_VOLTAGE = 1.1; // are we using default (5V) or internal (1.1V) for analogue measurements?
const int SENSOR_CHECK_DELAY = 5; // in seconds (short for testing)
const int LOOP_FREQ = 5; // milliseconds delay per loop
const int ERROR_SPEED = 125; // lower is faster flashes (25 IRL, slowed down by x5 for simulation)
const int ERROR_LENGTH = 10;
const unsigned long BUILD_UPLOAD_TIME = 30; // Approx. "Build + Upload" time (s) [DEBUG ONLY]
const unsigned long HW_START_1 = TIME_TO_SECS( 3,30, 0); // Morning
const unsigned long HW_STOP_1 = TIME_TO_SECS( 7,30, 0);
const unsigned long HW_START_2 = TIME_TO_SECS(16,50, 0); // Evening
const unsigned long HW_STOP_2 = TIME_TO_SECS(17, 0, 0);
const unsigned long CH_START_1 = TIME_TO_SECS( 5,30, 0); // Morning
const unsigned long CH_STOP_1 = TIME_TO_SECS( 7,30, 0);
const unsigned long CH_START_2 = TIME_TO_SECS(16,10, 0); // Evening
const unsigned long CH_STOP_2 = TIME_TO_SECS(20,10, 0);
// refers to the existing controller's start and stop times - use in combination with lastActuationX
const unsigned long SYS_START_1 = TIME_TO_SECS( 3,30, 0);
const unsigned long SYS_STOP_1 = TIME_TO_SECS( 7,30, 0);
const unsigned long SYS_START_2 = TIME_TO_SECS(16,50, 0);
const unsigned long SYS_STOP_2 = TIME_TO_SECS(17, 0, 0);
const int CHANGE_THRESH = TIME_TO_SECS( 0, 5, 0); // 5 mins between actuations
const int STATUS_THRESH = 2048; // TBD - LED ON/OFF Threshold
const int BURNER_THRESH = 3000; // TBD - Burning ON/OFF Threshold
const int HEATING = 0;
const int HOTWATER = 1; // NOTE - must be consecutive!
const int BURNER = 2;
const int LED_ARR_OFFSET = 3;
const int SERVO_ENGAGED_POS = 120; // TBD (we want this to be max of servo - to avoid spaz breakage)
const int SERVO_DISENGAGED_POS = 105; // TBD (looks like about 20 is enough for 2mm movement)
const int ACTUATION_DELAY = 150; // TBD (milliseconds)
const int HEATING_SERVO_PIN = 10;
const int HOTWATER_SERVO_PIN = 19;
const int HEATING_LED_PIN = 6; // Red
const int HOTWATER_LED_PIN = 9; // Blue
const int STATUS_LED_PIN = 8; // Yellow --- !! TODO !! - NEEDS TO BE SET TO 2 FOR M5 STAMP-C3 ---
const int HEATING_STATUS_PIN = 4;
const int HOTWATER_STATUS_PIN = 0;
const int BURNER_STATUS_PIN = A2; // this is a "nice to have" (for logging activity)
const int TEMP_PIN = 1;
const int PINS[] = {HEATING_STATUS_PIN, HOTWATER_STATUS_PIN, BURNER_STATUS_PIN,
HEATING_LED_PIN, HOTWATER_LED_PIN};
const int LOCAL_PORT = 8888;
const char* TIME_SERVER = "pool.ntp.org"; // originally: time.nist.gov
const int NTP_PACKET_SIZE = 48;
const int WEB_SERVER_PORT = 80;
// --- vars ---
// actuators
Servo heatingServo;
Servo hotWaterServo;
// last time a status was changed
unsigned long lastActuationHW = 0;
unsigned long lastActuationCH = 0;
// some concept of time and date
struct tm* date;
unsigned long timeOffset;
unsigned long currentTime;
unsigned long millisAtLastTimeStamp;
unsigned long lastSensorCheck;
// internet
byte packetBuffer[NTP_PACKET_SIZE];
WiFiUDP UDP;
WiFiServer server(WEB_SERVER_PORT);
bool netOK;
// other states
int statusLEDstate = OFF;
void indicateStatusProblem()
{
for (int i = 0; i < ERROR_LENGTH; i++)
{
neopixelWrite(STATUS_LED_PIN, OFF, OFF, OFF); // Off
delay(ERROR_SPEED);
neopixelWrite(STATUS_LED_PIN, RGB_BRIGHTNESS, RGB_BRIGHTNESS, OFF); // Yellow
delay(ERROR_SPEED);
}
if (statusLEDstate == ON)
neopixelWrite(STATUS_LED_PIN, RGB_BRIGHTNESS, RGB_BRIGHTNESS, OFF); // Yellow
else
neopixelWrite(STATUS_LED_PIN, OFF, OFF, OFF); // Off
}
void indicateProblem(int led)
{
// TODO change to allow parameterisation to indicate different problems
if (led == STATUS_LED_PIN)
{
indicateStatusProblem();
return;
}
int initStatus = digitalRead(led);
for (int i = 0; i < ERROR_LENGTH; i++)
{
digitalWrite(led, LOW);
delay(ERROR_SPEED);
digitalWrite(led, HIGH);
delay(ERROR_SPEED);
}
digitalWrite(led, initStatus);
}
// set up the hardware, the connection and open the UDP port
int initInternetTime()
{
Serial.print("\nConnecting to Wifi");
neopixelWrite(STATUS_LED_PIN, RGB_BRIGHTNESS, OFF, OFF); // Show Red till net connected
WiFi.begin("Wokwi-GUEST", "", 6);
while (WiFi.status() != WL_CONNECTED)
{
delay(250);
Serial.print(".");
}
Serial.println(" Connected\n");
UDP.begin(LOCAL_PORT);
// actually this is for web access not NTP stuff
server.begin();
return 1;
}
void sendPacketNTP(const char* address)
{
// set all bytes in the buffer to 0
memset(packetBuffer, 0, NTP_PACKET_SIZE);
// Initialize values needed to form NTP request
// (see URL above for details on the packets)
packetBuffer[0] = 0b11100011; // LI, Version, Mode
packetBuffer[1] = 0; // Stratum, or type of clock
packetBuffer[2] = 6; // Polling Interval
packetBuffer[3] = 0xEC; // Peer Clock Precision
// [8 bytes of zero for Root Delay & Root Dispersion]
packetBuffer[12] = 49;
packetBuffer[13] = 0x4E;
packetBuffer[14] = 49;
packetBuffer[15] = 52;
// all NTP fields have been given values, now
// you can send a packet requesting a timestamp:
UDP.beginPacket(address, 123); // NTP requests are to port 123
UDP.write(packetBuffer, NTP_PACKET_SIZE);
UDP.endPacket();
}
int getInternetTime()
{
sendPacketNTP(TIME_SERVER);
delay(1000);
if (UDP.parsePacket())
{
UDP.read(packetBuffer, NTP_PACKET_SIZE);
// the timestamp starts at byte 40 of the received packet and is four bytes,
// or two words, long. First, extract the two words:
unsigned long highWord = word(packetBuffer[40], packetBuffer[41]);
unsigned long lowWord = word(packetBuffer[42], packetBuffer[43]);
// combine the four bytes (two words) into a long integer
// this is NTP time (seconds since Jan 1 1900):
unsigned long secsSince1900 = highWord << 16 | lowWord;
// convert to a useful date structure fpr use in logging
time_t secsSince1970 = secsSince1900 - 2208988800UL;
date = gmtime(&secsSince1970);
timeOffset = secsSince1900 % 86400L; // (86400 equals secs per day)
millisAtLastTimeStamp = millis();
netOK = true;
return 1;
}
return 0;
}
// --- INITIALISATIONS ---
void setup()
{
// init. status pins (Neopixel init not needed)
pinMode(HEATING_LED_PIN, OUTPUT);
pinMode(HOTWATER_LED_PIN, OUTPUT);
// indicate Power On
statusLEDstate = ON;
neopixelWrite(STATUS_LED_PIN, RGB_BRIGHTNESS, RGB_BRIGHTNESS, OFF); // Yellow
digitalWrite(HEATING_LED_PIN, HIGH);
digitalWrite(HOTWATER_LED_PIN, HIGH);
// init. servos
heatingServo.attach(HEATING_SERVO_PIN);
hotWaterServo.attach(HOTWATER_SERVO_PIN);
// note we don't set lastActuationX as these are not actuations
heatingServo.write( SERVO_DISENGAGED_POS);
hotWaterServo.write(SERVO_DISENGAGED_POS);
delay(ACTUATION_DELAY * 2);
statusLEDstate = OFF;
neopixelWrite(STATUS_LED_PIN, OFF, OFF, OFF); // LOW
digitalWrite(HEATING_LED_PIN, LOW);
digitalWrite(HOTWATER_LED_PIN, LOW);
indicateProblem(STATUS_LED_PIN);
indicateProblem(HEATING_LED_PIN);
indicateProblem(HOTWATER_LED_PIN);
// this is a hack to ensure there is always a time reading in the system
// here we grab the time at time of compilation
int hh, mm, ss;
sscanf(__TIME__, "%2d %*c %2d %*c %2d", &hh, &mm, &ss);
timeOffset = TIME_TO_SECS(hh,mm,ss) + BUILD_UPLOAD_TIME;
millisAtLastTimeStamp = millis();
// all logging to serial
Serial.begin(56700);
// change analogue reference voltage (and burn 3 readings)
// NOTE: range on LM35 is <= 1.5V (150°C), so using the internal ~1.1V reference
// voltage will limit us to measurements of 110°C max. (oh noes!)
//analogReference(INTERNAL); analogRead(TEMP_PIN); analogRead(TEMP_PIN); analogRead(TEMP_PIN);
// make initial internet connection and grab the time from NTP server
if (initInternetTime() && getInternetTime())
{
statusLEDstate = ON;
neopixelWrite(STATUS_LED_PIN, RGB_BRIGHTNESS, RGB_BRIGHTNESS, OFF); // Yellow
}
// print headers
Serial.println(F("Date, Time, Hot Water Status, Burner Status, Heating Status, Temperature"));
} // --- end Setup ---
float readRadioSensor(int sensorID)
{
// future... possibly
return 0.0;
}
// returns temp in °C
float getTemp(int zone = 1)
{
// something to talk to the temp sensor (just one zone for now)
// added: 3 read averaging calibration
// --- calc for personal analogue sensor ---
//return (zone == 1 ? ((analogRead(TEMP_PIN) + analogRead(TEMP_PIN) + analogRead(TEMP_PIN)) / 3.0 ) / 1024.0 * 100 * REF_VOLTAGE - CH_CALIBRATION : readRadioSensor(zone));
// --- calc for wokwi analogue sensor --- BETA value
return (zone == 1 ? 1 / (log(1 / (4095.0 / ((analogRead(TEMP_PIN) + analogRead(TEMP_PIN) + analogRead(TEMP_PIN)) / 3) - 1)) / 3950 + 1.0 / 298.15) - 273.15 : readRadioSensor(zone));
}
// it may be nice to log when the burner is actually on
int logBurnerStatus()
{
/*Serial.print(F("BURNER_STATUS,"));
Serial.print(currentTime);
Serial.print(F(","));*/
Serial.println(checkStatus(BURNER));
return 0;
}
// similarly with if we have the heating on or off
int logHeatingStatus()
{
Serial.print(F("HEATING_STATUS,"));
Serial.print(currentTime);
Serial.print(F(","));
Serial.println(checkStatus(HEATING));
return 0;
}
// and of course the temperature too
int logTemp()
{
Serial.print(F("TEMP,"));
Serial.print(currentTime);
Serial.print(F(","));
Serial.println(getTemp());
return 0;
}
// other logs are handy for debugging (easy grep) but this will be the final log
int logEverything()
{
// CSV HEADER: Date, Time, Hot Water Status, Burner Status, Heating Status, Temperature
Serial.print(date->tm_mday);
Serial.print(F("/"));
Serial.print(date->tm_mon + 1); // vals are 0-11
Serial.print(F("/"));
Serial.print(date->tm_year + 1900); // vals are from 1900
Serial.print(F(","));
Serial.print(currentTime / 3600); // hours
Serial.print(F(":"));
Serial.print(currentTime % 3600 / 60); // minutes
Serial.print(F(":"));
Serial.print(currentTime % 3600 % 60); // seconds
Serial.print(F(","));
Serial.print(checkStatus(HOTWATER));
Serial.print(F(","));
Serial.print(checkStatus(BURNER));
Serial.print(F(","));
Serial.print(checkStatus(HEATING));
Serial.print(F(","));
Serial.print/*ln*/(getTemp());
// DEBUG
Serial.print(F(","));
Serial.print(analogRead(HEATING_STATUS_PIN));
Serial.print(F(","));
Serial.println(analogRead(HOTWATER_STATUS_PIN));
// future: add other zones if they become available
return 1;
}
void checkTempAndAdjustAccordingly()
{
// DISABLED TEMPORARY OVERRIDE AWAITING RELIABLE SENSOR
float temp = getTemp(); // CH_MIN_TEMP - 1
// under threshold so turn heating on
if (temp < CH_MIN_TEMP)
{
safeStatusChange(HEATING, ON);
}
// at, or over threshold so turn heating off
if (temp >= CH_MAX_TEMP)
{
safeStatusChange(HEATING, OFF);
}
}
// check status of BURNER, HEATING or HOTWATER
int checkStatus(int what)
{
int thresh = (what == BURNER ? BURNER_THRESH : STATUS_THRESH);
int value = analogRead(PINS[what]);
if (what != BURNER) // update status LEDs to reflect real system state, note: no BURNER status LED
{
digitalWrite(PINS[what + LED_ARR_OFFSET], value >= thresh ? ON : OFF);
}
else // the burner sends the value LOW when on, so need to invert
{
value = value * -1 + 4096;
}
return (value >= thresh ? ON : OFF);
}
// checks two things: do we need to actuate? and should we actuate? (avoid actuator spazzing)
int readyToActuate(int actuator, int desiredState)
{
// check not already in correct state
if (checkStatus(actuator) == desiredState)
{
return 0;
}
// find out when the last actuation was
unsigned long lastActuation;
if (actuator == HOTWATER)
{
lastActuation = lastActuationHW;
}
else
{
lastActuation = lastActuationCH;
}
// check if too little time passed since last actuation (including system actuations)
if (
(lastActuation < currentTime && currentTime < (lastActuation + CHANGE_THRESH) ) ||
(SYS_START_1 < currentTime && currentTime < (SYS_START_1 + CHANGE_THRESH) ) ||
(SYS_STOP_1 < currentTime && currentTime < (SYS_STOP_1 + CHANGE_THRESH) ) ||
(SYS_START_2 < currentTime && currentTime < (SYS_START_2 + CHANGE_THRESH) ) ||
(SYS_STOP_2 < currentTime && currentTime < (SYS_STOP_2 + CHANGE_THRESH) )
)
{
return 0;
}
return 1;
}
// change something either ON or OFF, verifying its state
// - what: either HEATING or HOTWATER
// - to: either ON or OFF
int safeStatusChange(int what, int to)
{
if (!readyToActuate(what, to))
{
return 0; // do nothing
}
// determine what we are actuating
Servo* actuator;
unsigned long* lastActuation;
if (what == HOTWATER)
{
lastActuation = &lastActuationHW;
actuator = &hotWaterServo;
}
else
{
lastActuation = &lastActuationCH;
actuator = &heatingServo;
}
// perform actuation
actuator->write(SERVO_ENGAGED_POS);
delay(ACTUATION_DELAY);
actuator->write(SERVO_DISENGAGED_POS);
delay(ACTUATION_DELAY);
// check for failed actuation
if (!(checkStatus(what) == to))
{
// HELP!! ACTUATOR FAILURE!.... Hello?.. Anyone?...
// TODO --- think of some (better) way of signifying failure ---
indicateProblem(PINS[what + LED_ARR_OFFSET]);
statusLEDstate = OFF;
neopixelWrite(STATUS_LED_PIN, OFF, OFF, OFF); // any good?... "if a tree falls..." -_=
return 0;
}
// set last actuation time - REMINDER: not set if above actuation check fails
*lastActuation = currentTime;
return 1;
}
int handleWebRequests()
{
WiFiClient client = server.available();
if (client)
{
// an http request ends with a blank line
bool currentLineIsBlank = true;
while (client.connected())
{
if (client.available()) // data available
{
char c = client.read();
// if you've gotten to the end of the line (received a newline
// character) and the line is blank, the http request has ended,
// so you can send a reply
if (c == '\n' && currentLineIsBlank)
{
// send a standard http response header
/* client.println(F("HTTP/1.1 200 OK"));
client.println(F("Content-Type: text/html"));
client.println(F("Connection: close")); // the connection will be closed after completion of the response
client.println(F("Refresh: 5")); // refresh the page automatically every 5 sec
client.println();
// HTML header
client.println(F("<!DOCTYPE HTML>"));
client.println(F("<HTML>"));
client.println(F("<HEAD><TITLE>HAS log</TITLE></HEAD>"));
client.println(F("<BODY>"));
client.println(F("<H2>HAS log</H2>"));
*/
client.print(F("\
HTTP/1.1 200 OK\n\
Content-Type: text/html\n\
Connection: close\n\
Refresh: 5\n\
\n\
<!DOCTYPE HTML>\n\
<HTML>\n\
<HEAD><TITLE>HAS log</TITLE></HEAD>\n\
<BODY>\n\
<H2>HAS log</H2>\n\
<TABLE>\n\
<TR><TD><B>Date</B></TD><TD><B>Time</B></TD><TD><B>Hot Water</B></TD><TD><B>Burner</B></TD><TD><B>Heating</B></TD><TD><B>Temperature (°C)</B></TD></TR>\n\
<TR><TD>"));
//client.print(date); // TODO replace with real date
client.print(date->tm_mday);
client.print(F("/"));
client.print(date->tm_mon + 1); // vals are 0-11
client.print(F("/"));
client.print(date->tm_year + 1900); // vals are from 1900
const __FlashStringHelper* htmlTableDataSep = F("</TD><TD>");
client.print(htmlTableDataSep);
//client.print(currentTime); // TODO replace with real time
client.print(currentTime / 3600); // hours
client.print(F(":"));
client.print(currentTime % 3600 / 60); // minutes
client.print(F(":"));
client.print(currentTime % 3600 % 60); // seconds
client.print(htmlTableDataSep);
client.print(checkStatus(HOTWATER));
client.print(htmlTableDataSep);
client.print(checkStatus(BURNER));
client.print(htmlTableDataSep);
client.print(checkStatus(HEATING));
client.print(htmlTableDataSep);
client.print(getTemp());
client.println(F("\
</TD></TR>\n\
</TABLE>\n\
</BODY>\n\
</HTML>"));
// page content
/* client.println(F("<PRE>"));
logFile = SD.open(LOG_FILE_NAME);
while (logFile.available())
{
client.print((char)logFile.read());
}
logFile.close();
client.println(F("</PRE>"));
*/ // HTML footer
// client.println(F("</BODY>"));
// client.println(F("</HTML>"));
break;
}
if (c == '\n')
{
// you're starting a new line
currentLineIsBlank = true;
}
else
{
if (c != '\r')
{
// you've gotten a character on the current line
currentLineIsBlank = false;
}
}
}
}
// give the web browser time to receive the data
delay(1);
// close the connection:
client.stop();
}
}
// --- MAIN LOOP ---
void loop()
{
// get currentTime (in seconds)
currentTime = ((millis() - millisAtLastTimeStamp) / 1000 + timeOffset) % 86400; // secs in a day
// no need to check sensor and (potentially) actuate too often - stuff doesn't change fast...
if (currentTime > ((lastSensorCheck + SENSOR_CHECK_DELAY) % 86400))
{
// record the last time we checked stuff
lastSensorCheck = currentTime;
// debug logging
/*logBurnerStatus();
logHeatingStatus();
logTemp();*/
// main logging
logEverything();
// ---- CONTROL HOT WATER ----
// ensure hotwater gets turned on
if (
(HW_START_1 < currentTime && currentTime < HW_STOP_1) || // in first period OR...
(HW_START_2 < currentTime && currentTime < HW_STOP_2) // ...in second period
)
{
safeStatusChange(HOTWATER, ON);
}
// ensure hotwater gets turned off
if (
(HW_STOP_1 < currentTime && currentTime < HW_START_2) || // between 1st and 2nd period OR...
HW_STOP_2 < currentTime || currentTime < HW_START_1 // ... before 1st OR after 2nd period
)
{
safeStatusChange(HOTWATER, OFF);
}
// ---- CONTROL HEATING ----
// ensure heating becomes active
if (
(currentTime > CH_START_1 && currentTime < CH_STOP_1) || // in first period OR...
(currentTime > CH_START_2 && currentTime < CH_STOP_2) // ...in second period
)
{
// we are in the heating requirement periods so...
checkTempAndAdjustAccordingly();
}
// ensure heating gets turned off
if (
(currentTime > CH_STOP_1 && currentTime < CH_START_2) || // between 1st and 2nd period OR...
currentTime > CH_STOP_2 || currentTime < CH_START_1 // ... before 1st OR after 2nd period
)
{
safeStatusChange(HEATING, OFF);
}
// ---- CONTROL CLOCK ----
if ( // we have internet, and time is between midnight and 5 past, and more than 5 mins since last time stamp
(netOK) &&
(TIME_TO_SECS( 0, 0, 0)) < currentTime && currentTime < (TIME_TO_SECS( 0, 5, 0)) &&
(millis() - millisAtLastTimeStamp) > (TIME_TO_SECS( 0, 5, 0) * 1000)
)
{
getInternetTime();
}
} // end of sensing and actuation stuff
if (netOK)
{
// ATTENTION!! Have commented out as think Wokwi might be trying to fire up
// local plugin since this is a server and not supported via remote wifi
//handleWebRequests();
}
// five miliseconds clock delay
delay(LOOP_FREQ);
} // --- END MAIN LOOP ---