/*
Copyright (C) 2024, Pablo César Galdo Regueiro.
info.wokwi(at)pcgaldo.com
Project in editing process.
Does not work on real hardware.
Problems with reboots on startup and freezes.
v1 works in real hardware:
https://wokwi.com/projects/391359771446388737
Press "Ctrl" to to lock a button press.
License
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>
*/
// Include Libraries
#include "Wire.h"
#include "Adafruit_GFX.h"
#include "Adafruit_SSD1306.h"
#include "RTClib.h"
#include "EEPROM.h"
#include "Bounce2.h"
// Splash Logo
#include "splash.h"
// Display Settings
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C
// OLED Display Instance
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
// Single value to check data validity
#define MAGIC_KEY 0x42
// Address in EEPROM where the magic key is to be stored
#define MAGIC_KEY_ADDR 0
// Variable to store the start time of the delay
unsigned long displayStartTime = 0;
// Delay time in milliseconds
const unsigned long displayDelay = 100;
// Variable to indicate if the delay is complete
bool displayUpdateComplete = false;
// Buttons input pins
#define IN_MENU 3
#define IN_DOWN 4
#define IN_UP 5
// Digital inputs debouncing
int dbintervalmenu = 150; // Pressing menu button time
int dbintervalupdown = 150; // Pressing menu button time
Bounce DB_MENU = Bounce();
Bounce DB_UP = Bounce();
Bounce DB_DOWN = Bounce();
// Debounced inputs
bool DB_IN_MENU = 0;
bool DB_IN_UP = 0;
bool DB_IN_DOWN = 0;
// Buttons Delay Settings
const int buttonsDelay = 500;
// Last Debounce Times for Buttons
unsigned long lastDelayTime = 0;
// Relay Output Pin
#define OUT_RELAY 2
// Real-Time Clock (RTC) Instance
RTC_DS1307 rtc;
// Temperature hysteresis
float t_hyst = 0.5;
// Thermistor Parameters
const float BETA = 3950;
// Setpoints
float t_sp; // Working setpoint
float t_sp1 = 17; // Setpoint 1
float t_sp2 = 20; // Setpoint 2
// Temperature
float t_pv; // Sensor temperature
// Relay State
bool relayState = true;
// Enumeration for Different Menu States
enum MenuState {
MAIN,
SETPOINT,
TIME_STATE,
DATETIME
};
MenuState menuState = MAIN;
// Enumeration for Different Setpoint States
enum SetpointState {
SP1,
SP2
};
SetpointState setpointState = SP1;
// Enumeration for Editing Setpoints
enum EditingSetpoint {
EDIT_SP1,
EDIT_SP2
};
EditingSetpoint editingSetpoint = EDIT_SP1; // Initially editing Setpoint 1
// Variable to track the current hour state for time-dependent settings
int dateTimeState = 0;
// Enumeration for Different Date and Time Fields
enum DateTimeField {
DAY,
MONTH,
YEAR,
HOUR,
MINUTE
};
DateTimeField WatchState = DAY;
// Enumeration for Days of the Week
enum Weekday {
SUNDAY,
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY
};
// Variables for blinking colons
unsigned long currentColonBlinkMillis = 0;
unsigned long lastColonBlinkMillis = 0;
bool colonVisible = true;
// Variable for blinking relay status
unsigned long currentRelayBlinkMillis = 0;
unsigned long lastRelayBlinkMillis = 0;
bool relayVisible = true;
// Flag to Track Button Presses
bool buttonPressed = false;
// Array to Store Time States
bool schedule[7][24];
int currentDay;
int currentHour;
int navDay = 1;
int navHour = 0;
// Current Date and Time
DateTime now;
// Variables to track the time since the last interaction and the display state
unsigned long lastInteractionTime = 0;
bool displayOn = true;
// ========== Initialization Functions ==========
void setup() {
Serial.begin(9600);
// Initialize the real-time clock (RTC)
if (!initializeRTC()) {
Serial.println(F("Couldn't find RTC"));
Serial.flush();
abort();
}
// Initialize the SSD1306 display
if (!initializeDisplay()) {
Serial.println(F("SSD1306 allocation failed"));
while (true);
}
// Set the output mode for the relay pin
pinMode(OUT_RELAY, OUTPUT);
digitalWrite(OUT_RELAY, LOW);
// Set the input mode for the buttons pins
pinMode(IN_MENU, INPUT);
DB_MENU.attach(IN_MENU);
DB_MENU.interval(dbintervalmenu);
pinMode(IN_UP, INPUT);
DB_UP.attach(IN_UP);
DB_UP.interval(dbintervalupdown);
pinMode(IN_DOWN, INPUT);
DB_DOWN.attach(IN_DOWN);
DB_DOWN.interval(dbintervalupdown);
// Initialize EEPROM values
initializeEEPROM();
// Initialize navigation variables
navDay = 1;
navHour = 0;
// Display a splash screen for 2 seconds
displaySplashScreen();
}
bool initializeDisplay() {
if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
Serial.println(F("Error: SSD1306 allocation failed. Check connections!"));
return false;
}
return true;
}
bool initializeRTC() {
if (!rtc.begin()) {
Serial.println(F("Error: Couldn't find RTC. Check connections!"));
return false;
}
return true;
}
void displaySplashScreen() {
display.clearDisplay();
display.drawBitmap(0, -10, splash, 128, 64, WHITE);
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 46);
display.print(F(" CHRONO-THERMOSTAT"));
display.setCursor(0, 56);
display.print(F("(c) Pablo Galdo, 2024"));
display.display();
delay(2000);
display.clearDisplay();
}
// ========== Main Program Loop ==========
void loop() {
// Update debounce instances
DB_MENU.update();
DB_UP.update();
DB_DOWN.update();
// Read debounced values
DB_IN_MENU = DB_MENU.read();
DB_IN_UP = DB_UP.read();
DB_IN_DOWN = DB_DOWN.read();
// Get the current time
unsigned long currentTime = millis();
// Check for inactivity for 1 minute
if ((currentTime - lastInteractionTime) > 60000 && displayOn) {
turnOffDisplay(); // Turn off the display if 1 minute has passed and it's on
}
// Get the current date and time from the RTC
now = rtc.now();
//currentDay = (now.dayOfTheWeek() + 6) % 7; // Set to start on Monday
currentDay = now.dayOfTheWeek();
currentHour = now.hour();
// Read analog sensor value and calculate temperature
int analogValue = analogRead(A0);
t_pv = calculateTemperature(analogValue);
// Update the setpoint (t_sp) based on the current state
if (schedule[currentDay][currentHour]) {
t_sp = t_sp2;
} else {
t_sp = t_sp1;
}
// Control the relay based on temperature and hysteresis
controlRelay(t_pv);
// Display current information on the OLED screen
updateDisplay();
// Execute actions based on the current menu state
executeMenuActions();
// Check if any button is pressed
if (DB_IN_MENU == 1 || DB_IN_DOWN == 1 || DB_IN_UP == 1) {
if (!displayOn) {
buttonPressed = true;
turnOnDisplay(); // Turn on the display if it was off
}
lastInteractionTime = currentTime; // Update the time of the last interaction
}
}
// ========== Program Functions ==========
// Function to turn off the display
void turnOffDisplay() {
display.ssd1306_command(SSD1306_DISPLAYOFF);
displayOn = false;
}
// Function to turn on the display
void turnOnDisplay() {
display.ssd1306_command(SSD1306_DISPLAYON);
displayOn = true;
}
// Function to calculate temperature from analog sensor value
float calculateTemperature(int analogValue) {
return 1 / (log(1 / (1023. / analogValue - 1)) / BETA + 1.0 / 298.15) - 273.15;
}
// Function to control the relay based on temperature and hysteresis
void controlRelay(float t_pv) {
if (t_pv <= t_sp - t_hyst) {
relayState = true;
digitalWrite(OUT_RELAY, HIGH);
} else if (t_pv >= t_sp + t_hyst) {
relayState = false;
digitalWrite(OUT_RELAY, LOW);
}
}
// Function to update the OLED display
void updateDisplay() {
// If the delay has not started, initiate it
if (!displayUpdateComplete) {
if (millis() - displayStartTime >= displayDelay) {
display.display();
display.clearDisplay();
displayUpdateComplete = true;
}
} else {
displayStartTime = millis();
displayUpdateComplete = false;
}
}
// Handle main menu interactions.
void handleMainMenu() {
editingSetpoint = EDIT_SP1;
dateTimeState = 0;
WatchState = DAY;
// Check if menu button is pressed
if (displayOn == true && DB_IN_MENU == 1 && DB_IN_DOWN == 0 && DB_IN_UP == 0) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
lastDelayTime = millis();
menuState = SETPOINT;
buttonPressed = true;
}
}
} else if (displayOn == true && DB_IN_MENU == 1 && DB_IN_DOWN == 1 && DB_IN_UP == 0) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
lastDelayTime = millis();
menuState = TIME_STATE;
buttonPressed = true;
}
}
} else if (displayOn == true && DB_IN_MENU == 1 && DB_IN_DOWN == 0 && DB_IN_UP == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
lastDelayTime = millis();
menuState = DATETIME;
buttonPressed = true;
}
}
} else {
buttonPressed = false;
}
}
// Handle setpoint menu interactions.
void handleSetpointMenu() {
if (displayOn == false) {
menuState = MAIN;
}
if (DB_IN_MENU == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
if (editingSetpoint == EDIT_SP1) {
editingSetpoint = EDIT_SP2;
} else {
saveSetpointsToEEPROM();
editingSetpoint = EDIT_SP2;
menuState = MAIN;
}
lastDelayTime = millis();
buttonPressed = true;
}
}
} else {
buttonPressed = false;
}
if (DB_IN_DOWN == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
switch (editingSetpoint) {
case EDIT_SP1:
t_sp1 = constrain(t_sp1 - 0.5, 15.0, min(t_sp2 - 0.5, 30.0));
break;
case EDIT_SP2:
t_sp2 = constrain(t_sp2 - 0.5, max(t_sp1 + 0.5, 15.0), 30.0);
break;
}
lastDelayTime = millis();
}
}
if (DB_IN_UP == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
switch (editingSetpoint) {
case EDIT_SP1:
t_sp1 = constrain(t_sp1 + 0.5, 15.0, min(t_sp2 - 0.5, 30.0));
break;
case EDIT_SP2:
t_sp2 = constrain(t_sp2 + 0.5, max(t_sp1 + 0.5, 15.0), 30.0);
break;
}
lastDelayTime = millis();
}
}
}
// Handle time state menu interactions.
void handleTimeStateMenu() {
if (displayOn == false) {
menuState = MAIN;
}
// Check if both up and down buttons are pressed
if (DB_IN_UP == 1 && DB_IN_DOWN == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
lastDelayTime = millis();
menuState = MAIN;
navDay = 1;
navHour = 0;
buttonPressed = true;
}
}
} else {
buttonPressed = false;
}
if (DB_IN_MENU == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
schedule[navDay][navHour] = !schedule[navDay][navHour];
saveScheduleToEEPROM();
lastDelayTime = millis();
}
}
}
if (DB_IN_UP == 1 && DB_IN_DOWN == 0) {
if (millis() - lastDelayTime > buttonsDelay) {
navHour++;
if (navHour > 23) navHour = 0;
lastDelayTime = millis();
}
}
if (DB_IN_DOWN == 1 && DB_IN_UP == 0) {
if (millis() - lastDelayTime > buttonsDelay) {
navDay++;
if (navDay > 6) navDay = 0;
lastDelayTime = millis();
}
}
}
// Handle date and time adjustment menu interactions.
void handleDateTimeMenu() {
if (displayOn == false) {
WatchState = DAY;
menuState = MAIN;
}
if (DB_IN_MENU == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
if (!buttonPressed) {
switch (WatchState) {
case DAY:
WatchState = MONTH;
break;
case MONTH:
WatchState = YEAR;
break;
case YEAR:
WatchState = HOUR;
break;
case HOUR:
WatchState = MINUTE;
break;
case MINUTE:
menuState = MAIN;
break;
}
lastDelayTime = millis();
buttonPressed = true;
}
}
} else {
buttonPressed = false;
}
if (DB_IN_DOWN == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
decrementDateTimeField();
lastDelayTime = millis();
}
}
if (DB_IN_UP == 1) {
if (millis() - lastDelayTime > buttonsDelay) {
incrementDateTimeField();
lastDelayTime = millis();
}
}
}
void incrementDateTimeField() {
DateTime now = rtc.now();
switch (WatchState) {
case DAY:
rtc.adjust(DateTime(now.year(), now.month(), (now.day() % 31) + 1, now.hour(), now.minute(), now.second()));
break;
case MONTH:
rtc.adjust(DateTime(now.year(), (now.month() % 12) + 1, now.day(), now.hour(), now.minute(), now.second()));
break;
case YEAR:
rtc.adjust(DateTime((now.year() % 2099) + 1, now.month(), now.day(), now.hour(), now.minute(), now.second()));
break;
case HOUR:
rtc.adjust(DateTime(now.year(), now.month(), now.day(), (now.hour() % 23) + 1, now.minute(), now.second()));
break;
case MINUTE:
rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), (now.minute() % 59) + 1, now.second()));
break;
}
}
void decrementDateTimeField() {
DateTime now = rtc.now();
switch (WatchState) {
case DAY:
rtc.adjust(DateTime(now.year(), now.month(), (now.day() == 1) ? 31 : now.day() - 1, now.hour(), now.minute(), now.second()));
break;
case MONTH:
rtc.adjust(DateTime(now.year(), (now.month() == 1) ? 12 : now.month() - 1, now.day(), now.hour(), now.minute(), now.second()));
break;
case YEAR:
rtc.adjust(DateTime((now.year() == 2000) ? 2099 : now.year() - 1, now.month(), now.day(), now.hour(), now.minute(), now.second()));
break;
case HOUR:
rtc.adjust(DateTime(now.year(), now.month(), now.day(), (now.hour() == 0) ? 23 : now.hour() - 1, now.minute(), now.second()));
break;
case MINUTE:
rtc.adjust(DateTime(now.year(), now.month(), now.day(), now.hour(), (now.minute() == 0) ? 59 : now.minute() - 1, now.second()));
break;
}
}
// Display the main screen on the OLED.
void displayMainScreen() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(2, 2);
if (now.day() < 10) {
display.print(F("0"));
}
display.print(now.day(), DEC);
display.print(F("/"));
if (now.month() < 10) {
display.print(F("0"));
}
display.print(now.month(), DEC);
display.print(F("/"));
display.println(now.year(), DEC);
display.setCursor(2, 12);
display.setTextSize(2);
currentColonBlinkMillis = millis();
if (currentColonBlinkMillis - lastColonBlinkMillis >= 500) {
colonVisible = !colonVisible;
lastColonBlinkMillis = currentColonBlinkMillis;
}
if (now.hour() < 10) {
display.print(F("0"));
}
display.print(now.hour(), DEC);
if (colonVisible) {
display.print(F(":"));
} else {
display.print(F(" "));
}
if (now.minute() < 10) {
display.print(F("0"));
}
display.println(now.minute(), DEC);
display.setCursor(2, 32);
display.setTextSize(1);
switch (now.dayOfTheWeek()) {
case SUNDAY:
display.print(F("SUNDAY"));
break;
case MONDAY:
display.print(F("MONDAY"));
break;
case TUESDAY:
display.print(F("TUESDAY"));
break;
case WEDNESDAY:
display.print(F("WEDNESDAY"));
break;
case THURSDAY:
display.print(F("THURSDAY"));
break;
case FRIDAY:
display.print(F("FRIDAY"));
break;
case SATURDAY:
display.print(F("SATURDAY"));
break;
}
display.setCursor(74, 2);
display.setTextSize(1);
display.print(F("TEMP."));
display.setCursor(74, 12);
display.setTextSize(2);
display.print(t_pv, 1);
display.setTextSize(1);
display.println(F("C"));
display.setCursor(74, 32);
display.print(F("SP:"));
display.print(t_sp, 1);
display.println(F("C"));
currentRelayBlinkMillis = millis();
if (currentRelayBlinkMillis - lastRelayBlinkMillis >= 250) {
relayVisible = !relayVisible;
lastRelayBlinkMillis = currentRelayBlinkMillis;
}
display.setCursor(34, 52);
if (relayState) {
if (relayVisible) {
display.print(F("HEATER ON"));
}
} else {
display.print(F("HEATER OFF"));
}
display.display();
}
// Display the setpoint menu on the OLED.
void displaySetpointScreen() {
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("TEMPERATURE SETPOINT"));
display.setCursor(0, 16);
display.print(F("SP LOW"));
if (editingSetpoint == EDIT_SP1) {
display.print(F(" > "));
} else {
display.print(F(" "));
}
display.setTextSize(2);
display.print(t_sp1, 1);
display.setTextSize(1);
display.print(F("C"));
display.setCursor(0, 40);
display.print(F("SP HI "));
if (editingSetpoint == EDIT_SP2) {
display.print(F(" > "));
} else {
display.print(F(" "));
}
display.setTextSize(2);
display.print(t_sp2, 1);
display.setTextSize(1);
display.print(F("C"));
display.display();
}
// Display the states and their status on the screen.
void displayTimeStateScreen() {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(12, 2);
display.println(F("SCHEDULER"));
display.setTextSize(2);
display.setCursor(12, 14);
switch (navDay) {
case 0:
display.print(F("SUNDAY"));
break;
case 1:
display.print(F("MONDAY"));
break;
case 2:
display.print(F("TUESDAY"));
break;
case 3:
display.print(F("WEDNESDAY"));
break;
case 4:
display.print(F("THURSDAY"));
break;
case 5:
display.print(F("FRIDAY"));
break;
case 6:
display.print(F("SATURDAY"));
break;
}
display.setTextSize(1);
display.setCursor(16, 36);
display.println(F("HORA:"));
display.setTextSize(2);
display.setCursor(16, 46);
display.print(navHour);
display.setTextSize(1);
display.setCursor(80, 36);
display.println(F("SP:"));
display.setTextSize(2);
display.setCursor(80, 46);
display.print(schedule[navDay][navHour] ? F("HI") : F("LOW"));
display.display();
}
// Display date and time on the OLED screen.
void displayDateTimeScreen() {
DateTime now = rtc.now();
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println(F("DATE & TIME ADJUST."));
display.setTextSize(2);
display.setCursor(0, 15);
if (WatchState == DAY) {
display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
}
if (now.day() < 10) {
display.print("0");
}
display.print(now.day(), DEC);
display.setTextColor(SSD1306_WHITE);
display.print(F("/"));
if (WatchState == MONTH) {
display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
}
if (now.month() < 10) {
display.print("0");
}
display.print(now.month(), DEC);
display.setTextColor(SSD1306_WHITE);
display.print(F("/"));
if (WatchState == YEAR) {
display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
}
display.print(now.year(), DEC);
display.setTextColor(SSD1306_WHITE);
display.println();
display.setCursor(0, 36);
if (WatchState == HOUR) {
display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
}
if (now.hour() < 10) {
display.print("0");
}
display.print(now.hour(), DEC);
display.setTextColor(SSD1306_WHITE);
display.print(F(":"));
if (WatchState == MINUTE) {
display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
}
if (now.minute() < 10) {
display.print("0");
}
display.print(now.minute(), DEC);
display.setTextColor(SSD1306_WHITE);
display.display();
}
void executeMenuActions() {
switch (menuState) {
case MAIN:
displayMainScreen();
handleMainMenu();
break;
case SETPOINT:
displaySetpointScreen();
handleSetpointMenu();
break;
case TIME_STATE:
displayTimeStateScreen();
handleTimeStateMenu();
break;
case DATETIME:
displayDateTimeScreen();
handleDateTimeMenu();
break;
}
}
void saveScheduleToEEPROM() {
int addr = 1; // Start after the magic key
for (int day = 0; day <= 6; day++) {
for (int hour = 0; hour < 24; hour++) {
EEPROM.write(addr++, schedule[day][hour]);
}
}
}
void saveSetpointsToEEPROM() {
int addr = 1 + 7 * 24; // Start after the schedule array
EEPROM.put(addr, t_sp1);
addr += sizeof(float);
EEPROM.put(addr, t_sp2);
}
void loadScheduleFromEEPROM() {
int addr = 1; // Start after the magic key
for (int day = 0; day <= 6; day++) {
for (int hour = 0; hour < 24; hour++) {
schedule[day][hour] = EEPROM.read(addr++);
}
}
}
void loadSetpointsFromEEPROM() {
int addr = 1 + 7 * 24; // Start after the schedule array
EEPROM.get(addr, t_sp1);
addr += sizeof(float);
EEPROM.get(addr, t_sp2);
}
void initializeSchedule() {
for (int day = 0; day <= 6; day++) {
for (int hour = 0; hour < 24; hour++) {
schedule[day][hour] = false;
}
}
}
void initializeEEPROM() {
// Check if the magic key is stored in EEPROM
if (EEPROM.read(MAGIC_KEY_ADDR) != MAGIC_KEY) {
// If invalid, load default values and save in EEPROM
initializeSchedule(); // Initialize schedule with default values
t_sp1 = 17.0; // Default low setpoint
t_sp2 = 20.0; // Default high setpoint
saveScheduleToEEPROM();
saveSetpointsToEEPROM();
// Save the magic key in the EEPROM
EEPROM.write(MAGIC_KEY_ADDR, MAGIC_KEY);
} else {
// If valid, load the values from the EEPROM
loadScheduleFromEEPROM();
loadSetpointsFromEEPROM();
}
}