2024-08-29 12:37:31 +07:00
|
|
|
#include <max6675.h>
|
|
|
|
|
#include <Wire.h>
|
|
|
|
|
#include <PID_v1.h>
|
2024-08-31 01:38:49 +07:00
|
|
|
#include <GyverOLED.h>
|
|
|
|
|
#include "GyverEncoder.h" // Include the GyverEncoder library
|
2024-08-29 12:37:31 +07:00
|
|
|
|
|
|
|
|
// MAX6675 configuration
|
|
|
|
|
int max_SO = 12;
|
|
|
|
|
int max_CS = 10;
|
|
|
|
|
int max_SCK = 13;
|
|
|
|
|
MAX6675 thermocouple(max_SCK, max_CS, max_SO);
|
|
|
|
|
|
2024-08-29 23:01:32 +07:00
|
|
|
// OLED configuration
|
2024-08-30 00:10:25 +07:00
|
|
|
GyverOLED<SSD1306_128x64> oled;
|
2024-08-29 12:37:31 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
// Encoder configuration
|
|
|
|
|
#define CLK 5
|
|
|
|
|
#define DT 6
|
|
|
|
|
#define SW 7
|
|
|
|
|
Encoder enc1(CLK, DT, SW, TYPE2);
|
|
|
|
|
|
2024-08-29 12:37:31 +07:00
|
|
|
// SSR pin configuration
|
|
|
|
|
const int ssrPin = 7;
|
|
|
|
|
|
2024-08-29 23:58:25 +07:00
|
|
|
// Profile structure definition
|
|
|
|
|
struct Phase {
|
|
|
|
|
int temperature;
|
|
|
|
|
int duration; // in minutes
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
struct Profile {
|
|
|
|
|
const char* name;
|
2024-08-30 02:45:48 +07:00
|
|
|
int transitionMinutesPerDegree;
|
2024-08-30 00:07:12 +07:00
|
|
|
Phase phases[6]; // Maximum of 6 phases per profile
|
2024-08-29 23:58:25 +07:00
|
|
|
int numPhases;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Profiles definition
|
|
|
|
|
Profile profiles[] = {
|
2024-08-30 02:45:48 +07:00
|
|
|
{"Test", 0, {{49, 1}, {51, 1}}, 2},
|
|
|
|
|
{"Пшеница", 1, {{47, 40}, {55, 40}, {65, 20}, {72, 20}, {85, 20}}, 5},
|
|
|
|
|
{"Veggies Sous Vide", 0, {{85, 120}}, 1},
|
|
|
|
|
{"Фитаза/Протеаза", 2, {{47, 120}, {53, 120}, {65, 150}, {72, 60}, {90, 105}, {50, 60}}, 6},
|
2024-08-29 23:58:25 +07:00
|
|
|
};
|
|
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
// Global variables for profile selection and execution
|
|
|
|
|
int activeProfileIndex = 100; // 100 indicates no profile selected yet
|
|
|
|
|
int selectedProfileIndex = 0; // Index of the currently selected profile in the selection mode
|
|
|
|
|
Profile activeProfile; // The active profile once selected
|
2024-08-29 12:37:31 +07:00
|
|
|
|
2024-08-30 02:45:48 +07:00
|
|
|
// Global variables for current phase, setpoint, and transition state
|
|
|
|
|
int currentPhase;
|
|
|
|
|
bool isInTransition = false;
|
2024-08-31 01:38:49 +07:00
|
|
|
bool inSelectionMode = true;
|
2024-08-30 02:45:48 +07:00
|
|
|
|
2024-08-29 12:37:31 +07:00
|
|
|
// PID Control variables
|
|
|
|
|
double Setpoint, Input, Output;
|
|
|
|
|
double Kp = 2, Ki = 0.5, Kd = 0.25;
|
|
|
|
|
PID myPID(&Input, &Output, &Setpoint, Kp, Ki, Kd, DIRECT);
|
|
|
|
|
|
|
|
|
|
// Timing variables
|
|
|
|
|
unsigned long phaseStartTime;
|
|
|
|
|
unsigned long totalStartTime;
|
|
|
|
|
unsigned long ssrLastSwitchTime;
|
2024-08-29 23:58:25 +07:00
|
|
|
unsigned long totalElapsedTime;
|
|
|
|
|
unsigned long totalProcessTime;
|
2024-08-30 00:23:18 +07:00
|
|
|
unsigned long finishTime = 0;
|
2024-08-31 01:46:02 +07:00
|
|
|
unsigned long currentTime = 0;
|
2024-08-30 00:23:18 +07:00
|
|
|
bool isComplete = false;
|
2024-08-29 12:37:31 +07:00
|
|
|
const int ssrSwitchInterval = 1000; // SSR switching interval in milliseconds
|
|
|
|
|
|
2024-08-29 23:58:25 +07:00
|
|
|
// Buffer for formatted time strings
|
|
|
|
|
char timeBuffer[10];
|
|
|
|
|
|
2024-08-29 12:37:31 +07:00
|
|
|
void setup() {
|
|
|
|
|
pinMode(ssrPin, OUTPUT);
|
|
|
|
|
Serial.begin(9600);
|
2024-08-29 23:01:32 +07:00
|
|
|
oled.init(); // Initialize the OLED
|
|
|
|
|
oled.clear(); // Clear the display
|
2024-08-31 01:38:49 +07:00
|
|
|
displaySelection();
|
2024-08-29 12:37:31 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void loop() {
|
2024-08-31 01:38:49 +07:00
|
|
|
enc1.tick(); // Mandatory encoder update function
|
|
|
|
|
|
|
|
|
|
if (inSelectionMode) {
|
|
|
|
|
handleProfileSelection(); // Handle profile selection mode
|
|
|
|
|
} else {
|
2024-08-31 01:46:02 +07:00
|
|
|
currentTime = millis();
|
2024-08-31 01:38:49 +07:00
|
|
|
totalElapsedTime = (currentTime - totalStartTime) / 1000; // Total elapsed time in seconds
|
|
|
|
|
|
|
|
|
|
getPhaseAndTemperature(totalElapsedTime);
|
|
|
|
|
|
2024-08-31 01:46:02 +07:00
|
|
|
Input = thermocouple.readCelsius();
|
|
|
|
|
if (isnan(Input)) {
|
|
|
|
|
Input = 0;
|
|
|
|
|
}
|
2024-08-31 01:38:49 +07:00
|
|
|
myPID.Compute();
|
|
|
|
|
|
|
|
|
|
// Switch SSR based on PID output and interval control
|
|
|
|
|
if (currentTime - ssrLastSwitchTime > ssrSwitchInterval) {
|
|
|
|
|
digitalWrite(ssrPin, Output > 0.5 ? HIGH : LOW);
|
|
|
|
|
ssrLastSwitchTime = currentTime;
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-31 01:46:02 +07:00
|
|
|
if (isComplete && currentPhase >= activeProfile.numPhases && !finishTime) {
|
2024-08-31 01:38:49 +07:00
|
|
|
finishTime = currentTime;
|
|
|
|
|
}
|
2024-08-30 02:45:48 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
// Display all phases and highlight the current one
|
2024-08-31 01:46:02 +07:00
|
|
|
printPhases();
|
2024-08-29 12:37:31 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
oled.update();
|
2024-08-30 02:45:48 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
delay(1000); // Update every second
|
2024-08-29 12:37:31 +07:00
|
|
|
}
|
2024-08-31 01:38:49 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void handleProfileSelection() {
|
2024-08-29 12:37:31 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
bool click = enc1.isClick();
|
|
|
|
|
bool turn = enc1.isTurn();
|
|
|
|
|
if (!click && !turn) {
|
|
|
|
|
return;
|
2024-08-30 02:45:48 +07:00
|
|
|
}
|
|
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
// Handle encoder input for selecting the profile
|
|
|
|
|
if (enc1.isRight()) {
|
|
|
|
|
selectedProfileIndex = (selectedProfileIndex + 1) % (sizeof(profiles) / sizeof(profiles[0]));
|
|
|
|
|
} else if (enc1.isLeft()) {
|
|
|
|
|
selectedProfileIndex = (selectedProfileIndex - 1 + (sizeof(profiles) / sizeof(profiles[0]))) % (sizeof(profiles) / sizeof(profiles[0]));
|
|
|
|
|
}
|
2024-08-30 02:45:48 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
displaySelection();
|
|
|
|
|
|
|
|
|
|
// Start the selected profile on button press
|
|
|
|
|
if (click) {
|
|
|
|
|
activeProfileIndex = selectedProfileIndex;
|
|
|
|
|
activeProfile = profiles[activeProfileIndex];
|
|
|
|
|
calculateTotalTime();
|
|
|
|
|
inSelectionMode = false; // Switch to execution mode
|
|
|
|
|
phaseStartTime = totalStartTime = millis(); // Start the timer
|
|
|
|
|
myPID.SetMode(AUTOMATIC);
|
|
|
|
|
myPID.SetOutputLimits(0, 1); // SSR is either ON or OFF
|
|
|
|
|
digitalWrite(ssrPin, HIGH); // Start with heater on
|
|
|
|
|
ssrLastSwitchTime = millis();
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-08-30 02:45:48 +07:00
|
|
|
|
2024-08-31 01:38:49 +07:00
|
|
|
void displaySelection() {
|
|
|
|
|
oled.clear();
|
|
|
|
|
// Display all profiles with the selected one highlighted
|
|
|
|
|
for (int i = 0; i < (int) (sizeof(profiles) / sizeof(profiles[0])); i++) {
|
|
|
|
|
if (i == selectedProfileIndex) {
|
|
|
|
|
oled.invertText(true); // Highlight the selected profile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oled.setCursor(0, i); // Set cursor to the correct row
|
|
|
|
|
oled.print(profiles[i].name);
|
|
|
|
|
|
|
|
|
|
oled.invertText(false); // Reset text inversion for other profiles
|
|
|
|
|
}
|
|
|
|
|
oled.update();
|
2024-08-30 02:45:48 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void calculateTotalTime() {
|
|
|
|
|
// Calculate total process time, including transitions
|
2024-08-29 23:58:25 +07:00
|
|
|
totalProcessTime = 0;
|
|
|
|
|
for (int i = 0; i < activeProfile.numPhases; i++) {
|
|
|
|
|
totalProcessTime += activeProfile.phases[i].duration * 60;
|
2024-08-30 02:45:48 +07:00
|
|
|
if (i < activeProfile.numPhases - 1) {
|
|
|
|
|
int tempDiff = abs(activeProfile.phases[i + 1].temperature - activeProfile.phases[i].temperature);
|
|
|
|
|
totalProcessTime += tempDiff * 60 * activeProfile.transitionMinutesPerDegree;
|
|
|
|
|
}
|
2024-08-29 12:37:31 +07:00
|
|
|
}
|
2024-08-30 02:45:48 +07:00
|
|
|
}
|
2024-08-29 12:37:31 +07:00
|
|
|
|
2024-08-30 02:45:48 +07:00
|
|
|
void getPhaseAndTemperature(unsigned long elapsedSeconds) {
|
|
|
|
|
unsigned long accumulatedTime = 0;
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < activeProfile.numPhases; i++) {
|
|
|
|
|
int phaseDuration = activeProfile.phases[i].duration * 60;
|
|
|
|
|
|
|
|
|
|
// Check for transition
|
|
|
|
|
if (i > 0) {
|
|
|
|
|
int previousTemp = activeProfile.phases[i - 1].temperature;
|
|
|
|
|
int targetTemp = activeProfile.phases[i].temperature;
|
|
|
|
|
int tempDiff = abs(targetTemp - previousTemp);
|
|
|
|
|
int transitionDuration = tempDiff * 60 * activeProfile.transitionMinutesPerDegree;
|
|
|
|
|
|
|
|
|
|
if (elapsedSeconds < accumulatedTime + transitionDuration) {
|
|
|
|
|
isInTransition = true;
|
2024-08-31 01:38:49 +07:00
|
|
|
currentPhase = i - 1; // Keep currentPhase as the previous phase
|
2024-08-30 02:45:48 +07:00
|
|
|
int timeInTransition = elapsedSeconds - accumulatedTime;
|
2024-08-31 01:38:49 +07:00
|
|
|
Setpoint = previousTemp + (double)timeInTransition / (60 * activeProfile.transitionMinutesPerDegree) * (targetTemp > previousTemp ? 1 : -1);
|
2024-08-30 02:45:48 +07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
accumulatedTime += transitionDuration;
|
2024-08-29 12:37:31 +07:00
|
|
|
}
|
|
|
|
|
|
2024-08-30 02:45:48 +07:00
|
|
|
// Check if we're within the current phase
|
|
|
|
|
if (elapsedSeconds < accumulatedTime + phaseDuration) {
|
|
|
|
|
isInTransition = false;
|
|
|
|
|
currentPhase = i;
|
|
|
|
|
Setpoint = activeProfile.phases[i].temperature;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-08-29 23:58:25 +07:00
|
|
|
|
2024-08-30 02:45:48 +07:00
|
|
|
accumulatedTime += phaseDuration;
|
|
|
|
|
}
|
2024-08-29 12:37:31 +07:00
|
|
|
|
2024-08-30 02:45:48 +07:00
|
|
|
// If the elapsed time exceeds the total duration, indicate completion
|
|
|
|
|
currentPhase = activeProfile.numPhases; // Indicate completion
|
|
|
|
|
Setpoint = 45; // Default to 45°C after completion
|
|
|
|
|
isInTransition = false;
|
|
|
|
|
isComplete = true; // Mark the process as complete
|
2024-08-29 12:37:31 +07:00
|
|
|
}
|
|
|
|
|
|
2024-08-31 01:46:02 +07:00
|
|
|
void printPhases() {
|
2024-08-29 23:58:25 +07:00
|
|
|
oled.clear();
|
|
|
|
|
|
2024-08-30 00:23:18 +07:00
|
|
|
if (isComplete) {
|
|
|
|
|
// Show completion time and current temperature instead of the title
|
|
|
|
|
oled.setCursor(0, 0);
|
|
|
|
|
oled.invertText(true);
|
2024-08-30 02:45:48 +07:00
|
|
|
oled.print("Done!");
|
|
|
|
|
oled.invertText(false);
|
|
|
|
|
oled.print(" ");
|
2024-08-30 00:23:18 +07:00
|
|
|
formatTime((currentTime - finishTime) / 1000, timeBuffer); // Time since completion
|
|
|
|
|
oled.print(timeBuffer);
|
|
|
|
|
oled.print(" ");
|
|
|
|
|
oled.print((int)Input);
|
|
|
|
|
oled.print("c");
|
|
|
|
|
} else {
|
|
|
|
|
oled.setCursor(0, 0);
|
|
|
|
|
oled.print(activeProfile.name);
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-29 23:58:25 +07:00
|
|
|
// Display the totals and current state on the OLED
|
|
|
|
|
oled.setCursor(18, 1);
|
|
|
|
|
formatTime(totalElapsedTime, timeBuffer);
|
|
|
|
|
oled.print(timeBuffer);
|
|
|
|
|
oled.print(" / ");
|
|
|
|
|
formatTime(totalProcessTime, timeBuffer);
|
|
|
|
|
oled.print(timeBuffer);
|
|
|
|
|
|
2024-08-31 01:46:02 +07:00
|
|
|
|
2024-08-29 23:58:25 +07:00
|
|
|
for (int i = 0; i < activeProfile.numPhases; i++) {
|
2024-08-30 00:23:18 +07:00
|
|
|
if (i == currentPhase && !isComplete) {
|
2024-08-29 23:58:25 +07:00
|
|
|
oled.invertText(true); // Invert text for the current phase
|
|
|
|
|
|
|
|
|
|
oled.setCursor(0, i + 2); // Set cursor to the row corresponding to the phase
|
2024-08-31 01:46:02 +07:00
|
|
|
unsigned long timeRemaining = (activeProfile.phases[i].duration * 60) - ((currentTime - phaseStartTime) / 1000);
|
2024-08-29 23:58:25 +07:00
|
|
|
formatTime(timeRemaining, timeBuffer);
|
|
|
|
|
oled.print(i + 1);
|
|
|
|
|
oled.print(". ");
|
2024-08-31 01:46:02 +07:00
|
|
|
oled.print(Input, 1);
|
2024-08-30 00:07:12 +07:00
|
|
|
oled.print("c ");
|
2024-08-30 02:45:48 +07:00
|
|
|
if (fabs(Setpoint - round(Setpoint)) < 0.05) {
|
|
|
|
|
oled.print((int)Setpoint); // Print without decimals
|
|
|
|
|
} else {
|
|
|
|
|
oled.print(Setpoint, 1); // Print with 1 decimal place
|
|
|
|
|
}
|
2024-08-30 00:07:12 +07:00
|
|
|
oled.print("c ");
|
2024-08-29 23:58:25 +07:00
|
|
|
oled.print(timeBuffer);
|
|
|
|
|
} else {
|
|
|
|
|
oled.invertText(false); // Normal text for other phases
|
|
|
|
|
|
|
|
|
|
oled.setCursor(0, i + 2); // Set cursor to the row corresponding to the phase
|
|
|
|
|
formatTime(activeProfile.phases[i].duration * 60, timeBuffer);
|
|
|
|
|
oled.print(i + 1);
|
|
|
|
|
oled.print(". ");
|
|
|
|
|
oled.print(activeProfile.phases[i].temperature);
|
2024-08-30 00:07:12 +07:00
|
|
|
oled.print("c ");
|
2024-08-29 23:58:25 +07:00
|
|
|
oled.print(timeBuffer);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
oled.invertText(false); // Ensure text inversion is off after the loop
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void formatTime(long seconds, char* buffer) {
|
2024-08-30 00:07:12 +07:00
|
|
|
long hours = seconds / 3600;
|
|
|
|
|
long mins = (seconds % 3600) / 60;
|
|
|
|
|
int secs = seconds % 60;
|
|
|
|
|
|
|
|
|
|
buffer[0] = '\0'; // Ensure the buffer is empty
|
|
|
|
|
|
|
|
|
|
if (hours > 0) {
|
|
|
|
|
sprintf(buffer + strlen(buffer), "%ldh", hours);
|
|
|
|
|
}
|
|
|
|
|
if (mins > 0) {
|
|
|
|
|
sprintf(buffer + strlen(buffer), "%ldm", mins);
|
|
|
|
|
}
|
|
|
|
|
if (secs > 0) {
|
|
|
|
|
sprintf(buffer + strlen(buffer), "%ds", secs);
|
|
|
|
|
}
|
2024-08-29 23:01:32 +07:00
|
|
|
}
|