DIY ESP32 Universal Timer with Touchscreen & Rotary Encoder
License: General Public License Arduino ESP32
In several of my previous videos, I presented different projects based on the CrowPanel 2.1-inch HMI ESP32 Rotary Display. This compact and very practical module combines a circular color touchscreen, an ESP32-S3 microcontroller, a rotary encoder with a push button, and an I2C connector for communicating with external devices. This combination of features makes it an ideal platform for my latest project — a universal timer with a clean, user-friendly interface and a very simple and intuitive way of setting the desired time.
The main idea was to create a timer that can be operated almost entirely with the rotary encoder. Turning the encoder sets the desired time, while a short press of the button starts, pauses, or resumes the countdown. A long press immediately resets the timer to zero. The circular display is used as a visual representation of the remaining time. The red area represents the selected or remaining time, while the white area gradually increases during the countdown. Thanks to framebuffer-based screen drawing, all graphic changes are smooth and free from unpleasant flickering. Four timer ranges are available: 1 minute, 10 minutes, 30 minutes, and 60 minutes.
The range can be changed simply by touching the lower part of the display. Each range also has its own scale color: yellow for 1 minute, turquoise for 10 minutes, orange for 30 minutes, and magenta for 60 minutes. The scale automatically changes according to the selected range, making it easy to estimate the remaining time even without reading the numerical display. For additional indication, I connected an external PCF8574 I2C I/O expander and an active buzzer.
When the countdown reaches zero, the display flashes red three times while the buzzer produces three synchronized beeps. The result is a compact and practical timer that is very easy to operate and can be useful in the workshop, kitchen, laboratory, photography, electronics experiments, or anywhere else where a simple visual countdown timer is needed.
A few recommendations regarding the installation of the code.
To avoid a black screen or unpleasant flashing, it is necessary to install ESP32 Core ver 2.0.17 on the Arduino and the Arduino_GFX v1.6.1 library. On the ESP32 S3, the flash size should be set to 16MB, Partition Scheme - Huge APP, and PSRAM - OPI PSRAM.
It can be noted that most of the parameters are defined at the beginning of the code, so various modifications can be made very easily, for example, changing the colors or geometry. As usual, the complete Arduino code and additional information about the project can be found in the description below the video.
Now let's see how the device performs in real conditions.
And finally, a short conclusion. This project shows how the CrowPanel 2.1-inch ESP32-S3 display can be transformed into a compact, intuitive, and highly practical universal timer with both visual and audible indication.
/*==============================================================
TIMER V1.0 FINAL by mircemk Oct. 2026
CrowPanel 2.1" ESP32-S3 Rotary Display
480 x 480
ESP32 Core : 2.0.17
Arduino_GFX : 1.4.4
TIMER RANGES
------------------------------------------------------------
1 MIN - Yellow
10 MIN - Turquoise / Blue-Green
30 MIN - Orange
60 MIN - Magenta
TOUCH
------------------------------------------------------------
Lower-center touch zone:
1 MIN -> 10 MIN -> 30 MIN -> 60 MIN -> 1 MIN
ENCODER
------------------------------------------------------------
Stable SLOW-CLEAN polling algorithm
CLK = GPIO4
DT = GPIO42
SHORT CLICK:
START / STOP / RESUME
LONG CLICK (~1.2 s):
RESET -> 00:00
Current range remains selected
BUZZER
------------------------------------------------------------
External PCF8574 = 0x20
P0 -> buzzer (-)
buzzer (+) -> 3.3V
P0 LOW = buzzer ON
P0 HIGH = buzzer OFF
TIMER FINISHED
------------------------------------------------------------
RED + BEEP 500 ms
WHITE + SILENT 500 ms
repeated 3 times
==============================================================*/
#include <Arduino.h>
#include <Arduino_GFX_Library.h>
#include <Wire.h>
#include <Adafruit_CST8XX.h>
#include <math.h>
// ============================================================
// DISPLAY
// ============================================================
#define DISPLAY_WIDTH 480
#define DISPLAY_HEIGHT 480
#define CX 240
#define CY 240
// ============================================================
// DISPLAY PINS
// ============================================================
#define PCLK_NEG 1
#define BL_PIN 6
#define PANEL_CS 16
#define PANEL_SCK 2
#define PANEL_SDA 1
// ============================================================
// ROTARY ENCODER
// ============================================================
#define ENCODER_CLK 4
#define ENCODER_DT 42
// ============================================================
// I2C
// ============================================================
#define I2C_SDA 38
#define I2C_SCL 39
// Built-in PCF8574
#define INTERNAL_PCF_ADDR 0x21
// External PCF8574
#define EXTERNAL_PCF_ADDR 0x20
// Touch
#define TOUCH_ADDR 0x15
// ============================================================
// BUZZER
// External PCF8574 P0
// ============================================================
#define BUZZER_BIT 0
uint8_t externalPcfState = 0xFF;
// ============================================================
// DISPLAY OBJECTS
// ============================================================
Arduino_DataBus *panelBus = nullptr;
Arduino_ESP32RGBPanel *rgbpanel = nullptr;
Arduino_RGB_Display *display = nullptr;
Arduino_Canvas *gfx = nullptr;
extern const uint8_t st7701_type7_init_operations[];
// ============================================================
// TOUCH
// ============================================================
Adafruit_CST8XX touch = Adafruit_CST8XX();
bool touchWasDown = false;
bool rangeTouchCandidate = false;
unsigned long lastTouchPollMillis = 0;
const unsigned long TOUCH_POLL_MS = 20;
// ============================================================
// COLORS
// ============================================================
#define COL_BLACK 0x0000
#define COL_WHITE 0xFFFF
#define COL_RED 0xF800
// Range colors
#define COL_1MIN 0xFFE0 // Yellow
#define COL_10MIN 0x06F7 // Turquoise
#define COL_30MIN 0xFD20 // Orange
#define COL_60MIN 0xF81F // Magenta
// ============================================================
// TIMER GEOMETRY
// ============================================================
const int OUTER_RADIUS = 235;
const int SEPARATOR_RADIUS = 203;
const int INNER_RADIUS = 200;
const int TICK_OUTER_RADIUS = 228;
const int TICK_MINOR_INNER = 216;
const int TICK_MAJOR_INNER = 211;
const int NUMBER_RADIUS = 174;
// ============================================================
// TOUCH ZONE FOR RANGE SELECTION
// ============================================================
const int RANGE_TOUCH_X1 = 115;
const int RANGE_TOUCH_X2 = 365;
const int RANGE_TOUCH_Y1 = 290;
const int RANGE_TOUCH_Y2 = 435;
// ============================================================
// TIMER RANGES
// ============================================================
enum TimerRange
{
RANGE_1_MIN,
RANGE_10_MIN,
RANGE_30_MIN,
RANGE_60_MIN
};
TimerRange currentRange = RANGE_1_MIN;
// ============================================================
// TIMER VALUE / STATE
// ============================================================
int remainingSeconds = 0;
bool timerRunning = false;
bool countdownHasStarted = false;
unsigned long lastSecondMillis = 0;
// ============================================================
// STABLE ENCODER SETTINGS
// ============================================================
const int ENCODER_DIRECTION = -1;
const unsigned long STABLE_TIME_MS = 8;
const unsigned long CLICK_LOCKOUT_MS = 70;
// ============================================================
// ENCODER STATE
// ============================================================
int lastRawCLK = HIGH;
int stableCLK = HIGH;
unsigned long clkChangeTime = 0;
unsigned long lastClickTime = 0;
bool readyForNextClick = true;
// ============================================================
// BUTTON STATE
// ============================================================
bool rawButtonLast = false;
bool stableButtonState = false;
unsigned long buttonDebounceMillis = 0;
const unsigned long BUTTON_DEBOUNCE_MS = 40;
const unsigned long BUTTON_POLL_MS = 5;
unsigned long lastButtonPollMillis = 0;
// ============================================================
// LONG PRESS
// ============================================================
const unsigned long LONG_PRESS_MS = 1200;
unsigned long buttonPressStartMillis = 0;
bool longPressHandled = false;
// ============================================================
// RANGE HELPERS
// ============================================================
int getMaxSeconds()
{
switch (currentRange)
{
case RANGE_1_MIN:
return 60;
case RANGE_10_MIN:
return 600;
case RANGE_30_MIN:
return 1800;
case RANGE_60_MIN:
return 3600;
}
return 60;
}
// ============================================================
// ENCODER STEP
// ============================================================
int getEncoderStepSeconds()
{
switch (currentRange)
{
case RANGE_1_MIN:
// 60 fields
return 1;
case RANGE_10_MIN:
// 50 fields
// 600 / 50 = 12 sec
return 12;
case RANGE_30_MIN:
// 30 fields
// 1800 / 30 = 60 sec
return 60;
case RANGE_60_MIN:
// 60 fields
// 3600 / 60 = 60 sec
return 60;
}
return 1;
}
// ============================================================
// RANGE COLOR
// ============================================================
uint16_t getRingColor()
{
switch (currentRange)
{
case RANGE_1_MIN:
return COL_1MIN;
case RANGE_10_MIN:
return COL_10MIN;
case RANGE_30_MIN:
return COL_30MIN;
case RANGE_60_MIN:
return COL_60MIN;
}
return COL_1MIN;
}
// ============================================================
// RANGE TEXT
// ============================================================
const char *getRangeText()
{
switch (currentRange)
{
case RANGE_1_MIN:
return "1 MIN";
case RANGE_10_MIN:
return "10 MIN";
case RANGE_30_MIN:
return "30 MIN";
case RANGE_60_MIN:
return "60 MIN";
}
return "1 MIN";
}
// ============================================================
// POLAR COORDINATES
// ============================================================
void polarPoint(float angleDeg,
int radius,
int &x,
int &y)
{
float a =
angleDeg * PI / 180.0f;
x =
CX + round(radius * sin(a));
y =
CY - round(radius * cos(a));
}
// ============================================================
// RADIAL SCALE TICK
// ============================================================
void drawRadialTick(float angleDeg,
int innerRadius,
int outerRadius,
float thickness,
uint16_t color)
{
float a =
angleDeg * PI / 180.0f;
float rx = sin(a);
float ry = -cos(a);
float px = cos(a);
float py = sin(a);
float halfThickness =
thickness / 2.0f;
float ix =
CX + innerRadius * rx;
float iy =
CY + innerRadius * ry;
float ox =
CX + outerRadius * rx;
float oy =
CY + outerRadius * ry;
int x1 =
round(ix + px * halfThickness);
int y1 =
round(iy + py * halfThickness);
int x2 =
round(ix - px * halfThickness);
int y2 =
round(iy - py * halfThickness);
int x3 =
round(ox - px * halfThickness);
int y3 =
round(oy - py * halfThickness);
int x4 =
round(ox + px * halfThickness);
int y4 =
round(oy + py * halfThickness);
gfx->fillTriangle(
x1, y1,
x2, y2,
x3, y3,
color
);
gfx->fillTriangle(
x1, y1,
x3, y3,
x4, y4,
color
);
}
// ============================================================
// BOLD TEXT
// ============================================================
void drawBoldText(const char *text,
int x,
int y,
int textSize,
uint16_t color)
{
gfx->setTextSize(
textSize
);
gfx->setTextColor(
color
);
gfx->setCursor(x, y);
gfx->print(text);
gfx->setCursor(x + 1, y);
gfx->print(text);
gfx->setCursor(x, y + 1);
gfx->print(text);
gfx->setCursor(x + 1, y + 1);
gfx->print(text);
}
// ============================================================
// CENTERED BOLD TEXT
// ============================================================
void drawCenteredBoldText(const char *text,
int centerX,
int y,
int textSize,
uint16_t color)
{
int textWidth =
strlen(text) *
6 *
textSize;
int x =
centerX -
textWidth / 2;
drawBoldText(
text,
x,
y,
textSize,
color
);
}
// ============================================================
// GENERIC SCALE
// ============================================================
void drawGenericScale(int totalMarks,
int marksPerMajor)
{
float angleStep =
360.0f /
(float)totalMarks;
for (int i = 0;
i < totalMarks;
i++)
{
float angle =
i * angleStep;
bool major =
(i % marksPerMajor == 0);
if (major)
{
drawRadialTick(
angle,
TICK_MAJOR_INNER,
TICK_OUTER_RADIUS,
6.0f,
COL_BLACK
);
}
else
{
drawRadialTick(
angle,
TICK_MINOR_INNER,
TICK_OUTER_RADIUS,
3.0f,
COL_BLACK
);
}
}
}
// ============================================================
// DRAW SCALE
// ============================================================
void drawScale()
{
switch (currentRange)
{
case RANGE_1_MIN:
drawGenericScale(
60,
5
);
break;
case RANGE_10_MIN:
drawGenericScale(
50,
5
);
break;
case RANGE_30_MIN:
drawGenericScale(
30,
5
);
break;
case RANGE_60_MIN:
drawGenericScale(
60,
5
);
break;
}
}
// ============================================================
// DRAW ONE SCALE NUMBER
// ============================================================
void drawScaleNumber(float angle,
int value)
{
int x;
int y;
polarPoint(
angle,
NUMBER_RADIUS,
x,
y
);
char text[5];
sprintf(
text,
"%d",
value
);
const int textSize =
3;
int textWidth =
strlen(text) *
6 *
textSize;
int textHeight =
8 *
textSize;
drawBoldText(
text,
x - textWidth / 2,
y - textHeight / 2,
textSize,
COL_BLACK
);
}
// ============================================================
// DRAW SCALE NUMBERS
// ============================================================
void drawNumbers()
{
// ==========================================================
// 1 MIN
// 0,55,50 ... 5
// ==========================================================
if (currentRange == RANGE_1_MIN)
{
for (int pos = 0;
pos < 12;
pos++)
{
float angle =
pos * 30.0f;
int value;
if (pos == 0)
{
value = 0;
}
else
{
value =
60 -
pos * 5;
}
drawScaleNumber(
angle,
value
);
}
return;
}
// ==========================================================
// 10 MIN
// 0,9,8 ... 1
// ==========================================================
if (currentRange == RANGE_10_MIN)
{
for (int pos = 0;
pos < 10;
pos++)
{
float angle =
pos * 36.0f;
int value;
if (pos == 0)
{
value = 0;
}
else
{
value =
10 -
pos;
}
drawScaleNumber(
angle,
value
);
}
return;
}
// ==========================================================
// 30 MIN
// 0,25,20,15,10,5
// ==========================================================
if (currentRange == RANGE_30_MIN)
{
for (int pos = 0;
pos < 6;
pos++)
{
float angle =
pos * 60.0f;
int value;
if (pos == 0)
{
value = 0;
}
else
{
value =
30 -
pos * 5;
}
drawScaleNumber(
angle,
value
);
}
return;
}
// ==========================================================
// 60 MIN
// 0,55,50 ... 5
// ==========================================================
if (currentRange == RANGE_60_MIN)
{
for (int pos = 0;
pos < 12;
pos++)
{
float angle =
pos * 30.0f;
int value;
if (pos == 0)
{
value = 0;
}
else
{
value =
60 -
pos * 5;
}
drawScaleNumber(
angle,
value
);
}
}
}
// ============================================================
// RED REMAINING TIME SECTOR
// ============================================================
void drawRemainingRedSector()
{
int maxSeconds =
getMaxSeconds();
if (remainingSeconds <= 0)
{
return;
}
if (remainingSeconds >= maxSeconds)
{
gfx->fillCircle(
CX,
CY,
INNER_RADIUS,
COL_RED
);
return;
}
float redAngle =
((float)remainingSeconds /
(float)maxSeconds)
*
360.0f;
float startAngle =
360.0f -
redAngle;
for (float angle = startAngle;
angle < 360.0f;
angle += 0.7f)
{
int x1;
int y1;
int x2;
int y2;
polarPoint(
angle,
INNER_RADIUS,
x1,
y1
);
polarPoint(
angle + 1.0f,
INNER_RADIUS,
x2,
y2
);
gfx->fillTriangle(
CX,
CY,
x1,
y1,
x2,
y2,
COL_RED
);
}
}
// ============================================================
// FORMAT TIMER
// ============================================================
void formatTimer(char *buffer)
{
int minutes =
remainingSeconds /
60;
int seconds =
remainingSeconds %
60;
sprintf(
buffer,
"%02d:%02d",
minutes,
seconds
);
}
// ============================================================
// COMPLETE TIMER FRAME
// ============================================================
void renderTimerFrame()
{
gfx->fillScreen(
COL_BLACK
);
// Colored range ring
gfx->fillCircle(
CX,
CY,
OUTER_RADIUS,
getRingColor()
);
// Separator
gfx->fillCircle(
CX,
CY,
SEPARATOR_RADIUS,
COL_BLACK
);
// White timer area
gfx->fillCircle(
CX,
CY,
INNER_RADIUS,
COL_WHITE
);
// Red remaining area
drawRemainingRedSector();
drawScale();
drawNumbers();
char timerText[8];
formatTimer(
timerText
);
drawCenteredBoldText(
timerText,
CX,
184,
7,
COL_BLACK
);
drawCenteredBoldText(
getRangeText(),
CX,
315,
4,
COL_BLACK
);
// Flicker-free screen update
gfx->flush();
}
// ============================================================
// ALARM FLASH FRAME
// ============================================================
void renderFlashFrame(uint16_t innerColor)
{
gfx->fillScreen(
COL_BLACK
);
gfx->fillCircle(
CX,
CY,
OUTER_RADIUS,
getRingColor()
);
gfx->fillCircle(
CX,
CY,
SEPARATOR_RADIUS,
COL_BLACK
);
gfx->fillCircle(
CX,
CY,
INNER_RADIUS,
innerColor
);
drawScale();
drawNumbers();
drawCenteredBoldText(
"00:00",
CX,
184,
7,
COL_BLACK
);
drawCenteredBoldText(
getRangeText(),
CX,
315,
4,
COL_BLACK
);
gfx->flush();
}
// ============================================================
// INTERNAL PCF8574
// Address 0x21
// ============================================================
void internalPcfWrite(uint8_t value)
{
Wire.beginTransmission(
INTERNAL_PCF_ADDR
);
Wire.write(
value
);
Wire.endTransmission();
}
uint8_t internalPcfRead()
{
Wire.requestFrom(
INTERNAL_PCF_ADDR,
(uint8_t)1
);
if (Wire.available())
{
return Wire.read();
}
return 0xFF;
}
// ============================================================
// EXTERNAL PCF8574
// Address 0x20
// ============================================================
void externalPcfWrite()
{
Wire.beginTransmission(
EXTERNAL_PCF_ADDR
);
Wire.write(
externalPcfState
);
Wire.endTransmission();
}
// ============================================================
// BUZZER CONTROL
//
// Buzzer + = 3.3V
// Buzzer - = P0
//
// LOW = ON
// HIGH = OFF
// ============================================================
void buzzerON()
{
externalPcfState &=
~(1 << BUZZER_BIT);
externalPcfWrite();
}
void buzzerOFF()
{
externalPcfState |=
(1 << BUZZER_BIT);
externalPcfWrite();
}
// ============================================================
// TOUCH RESET
// Internal PCF8574 P0
// ============================================================
void resetTouch()
{
internalPcfWrite(
0xFF
);
delay(
20
);
// P0 LOW
internalPcfWrite(
0xFE
);
delay(
30
);
// P0 HIGH
internalPcfWrite(
0xFF
);
delay(
150
);
}
// ============================================================
// ENCODER BUTTON
// Internal PCF8574 P5
// ============================================================
bool isEncoderButtonPressed()
{
uint8_t state =
internalPcfRead();
return
!(state & (1 << 5));
}
// ============================================================
// RANGE TOUCH ZONE
// ============================================================
bool isRangeTouchZone(int x,
int y)
{
return
x >= RANGE_TOUCH_X1 &&
x <= RANGE_TOUCH_X2 &&
y >= RANGE_TOUCH_Y1 &&
y <= RANGE_TOUCH_Y2;
}
// ============================================================
// RESET TIMER
//
// LONG PRESS
// ============================================================
void resetTimer()
{
timerRunning =
false;
countdownHasStarted =
false;
remainingSeconds =
0;
lastSecondMillis =
millis();
// Ensure alarm is silent
buzzerOFF();
// Re-sync encoder
lastRawCLK =
digitalRead(
ENCODER_CLK
);
stableCLK =
lastRawCLK;
clkChangeTime =
millis();
lastClickTime =
millis();
readyForNextClick =
true;
// Touch state
touchWasDown =
false;
rangeTouchCandidate =
false;
renderTimerFrame();
Serial.println(
"TIMER RESET -> 00:00"
);
}
// ============================================================
// CHANGE TIMER RANGE
// ============================================================
void changeTimerRange()
{
if (countdownHasStarted)
{
return;
}
switch (currentRange)
{
case RANGE_1_MIN:
currentRange =
RANGE_10_MIN;
break;
case RANGE_10_MIN:
currentRange =
RANGE_30_MIN;
break;
case RANGE_30_MIN:
currentRange =
RANGE_60_MIN;
break;
case RANGE_60_MIN:
currentRange =
RANGE_1_MIN;
break;
}
remainingSeconds =
0;
buzzerOFF();
// Re-sync encoder
lastRawCLK =
digitalRead(
ENCODER_CLK
);
stableCLK =
lastRawCLK;
clkChangeTime =
millis();
lastClickTime =
millis();
readyForNextClick =
true;
renderTimerFrame();
Serial.print(
"RANGE: "
);
Serial.println(
getRangeText()
);
}
// ============================================================
// TOUCH HANDLER
// ============================================================
void handleTouch()
{
unsigned long now =
millis();
if (
now -
lastTouchPollMillis
<
TOUCH_POLL_MS
)
{
return;
}
lastTouchPollMillis =
now;
uint8_t touches =
touch.touched();
// ----------------------------------------------------------
// TOUCH DOWN
// ----------------------------------------------------------
if (touches > 0)
{
CST_TS_Point p =
touch.getPoint(0);
if (!touchWasDown)
{
touchWasDown =
true;
rangeTouchCandidate =
!countdownHasStarted &&
isRangeTouchZone(
p.x,
p.y
);
}
return;
}
// ----------------------------------------------------------
// RELEASE
// ----------------------------------------------------------
if (touchWasDown)
{
touchWasDown =
false;
if (rangeTouchCandidate)
{
rangeTouchCandidate =
false;
changeTimerRange();
}
else
{
rangeTouchCandidate =
false;
}
}
}
// ============================================================
// STABLE DT MAJORITY READ
//
// BEST WORKING ENCODER METHOD
// ============================================================
int readStableDT()
{
int highCount = 0;
int lowCount = 0;
for (int i = 0;
i < 7;
i++)
{
if (
digitalRead(
ENCODER_DT
)
)
{
highCount++;
}
else
{
lowCount++;
}
delayMicroseconds(
250
);
}
return
(highCount > lowCount)
?
HIGH
:
LOW;
}
// ============================================================
// ACCEPT ENCODER CLICK
// ============================================================
void acceptEncoderClick(int direction)
{
if (countdownHasStarted)
{
return;
}
int oldValue =
remainingSeconds;
int stepSeconds =
getEncoderStepSeconds();
remainingSeconds +=
direction *
stepSeconds;
int maxSeconds =
getMaxSeconds();
if (remainingSeconds < 0)
{
remainingSeconds =
0;
}
if (remainingSeconds > maxSeconds)
{
remainingSeconds =
maxSeconds;
}
if (
remainingSeconds !=
oldValue
)
{
renderTimerFrame();
Serial.print(
"SET: "
);
Serial.print(
remainingSeconds
);
Serial.print(
" sec "
);
Serial.println(
getRangeText()
);
}
}
// ============================================================
// SLOW-CLEAN ENCODER
//
// DO NOT MODIFY
// ============================================================
void handleEncoder()
{
unsigned long now =
millis();
int rawCLK =
digitalRead(
ENCODER_CLK
);
// Raw CLK changed
if (
rawCLK !=
lastRawCLK
)
{
lastRawCLK =
rawCLK;
clkChangeTime =
now;
}
// Stable for at least 8 ms
if (
rawCLK != stableCLK &&
(now - clkChangeTime)
>=
STABLE_TIME_MS
)
{
stableCLK =
rawCLK;
// --------------------------------------------------------
// Stable falling edge
// --------------------------------------------------------
if (
stableCLK == LOW &&
readyForNextClick &&
(now - lastClickTime)
>=
CLICK_LOCKOUT_MS
)
{
int dt =
readStableDT();
int direction;
if (dt == HIGH)
{
direction =
+1;
}
else
{
direction =
-1;
}
direction *=
ENCODER_DIRECTION;
acceptEncoderClick(
direction
);
lastClickTime =
now;
readyForNextClick =
false;
}
// --------------------------------------------------------
// Re-arm after CLK returns HIGH
// --------------------------------------------------------
if (
stableCLK ==
HIGH
)
{
readyForNextClick =
true;
}
}
}
// ============================================================
// SHORT CLICK
// START / STOP / RESUME
// ============================================================
void encoderButtonAction()
{
// ----------------------------------------------------------
// RUNNING -> PAUSE
// ----------------------------------------------------------
if (timerRunning)
{
timerRunning =
false;
Serial.println(
"TIMER PAUSED"
);
return;
}
// ----------------------------------------------------------
// PAUSED -> RESUME
// ----------------------------------------------------------
if (countdownHasStarted)
{
timerRunning =
true;
lastSecondMillis =
millis();
Serial.println(
"TIMER RESUMED"
);
return;
}
// ----------------------------------------------------------
// NEW START
// ----------------------------------------------------------
if (remainingSeconds > 0)
{
countdownHasStarted =
true;
timerRunning =
true;
lastSecondMillis =
millis();
Serial.println(
"TIMER STARTED"
);
}
}
// ============================================================
// BUTTON HANDLER
//
// SHORT click -> action on RELEASE
// LONG click -> RESET after 1.2 sec
// ============================================================
void handleButton()
{
unsigned long now =
millis();
if (
now -
lastButtonPollMillis
<
BUTTON_POLL_MS
)
{
return;
}
lastButtonPollMillis =
now;
bool rawState =
isEncoderButtonPressed();
// Raw state changed
if (
rawState !=
rawButtonLast
)
{
rawButtonLast =
rawState;
buttonDebounceMillis =
now;
}
// Debounced state
if (
now -
buttonDebounceMillis
>=
BUTTON_DEBOUNCE_MS
)
{
if (
rawState !=
stableButtonState
)
{
stableButtonState =
rawState;
// ======================================================
// PRESSED
// ======================================================
if (stableButtonState)
{
buttonPressStartMillis =
now;
longPressHandled =
false;
}
// ======================================================
// RELEASED
// ======================================================
else
{
// No long press -> normal short click
if (!longPressHandled)
{
encoderButtonAction();
}
}
}
}
// ==========================================================
// LONG PRESS
// ==========================================================
if (
stableButtonState &&
!longPressHandled &&
(now - buttonPressStartMillis)
>=
LONG_PRESS_MS
)
{
longPressHandled =
true;
resetTimer();
}
}
// ============================================================
// TIMER FINISHED ALARM
//
// RED + BUZZER = 500 ms
// WHITE + OFF = 500 ms
//
// x3
// ============================================================
void flashFinished()
{
for (int i = 0;
i < 3;
i++)
{
// --------------------------------------------------------
// RED + BEEP
// --------------------------------------------------------
renderFlashFrame(
COL_RED
);
buzzerON();
delay(
500
);
// --------------------------------------------------------
// WHITE + SILENT
// --------------------------------------------------------
buzzerOFF();
renderFlashFrame(
COL_WHITE
);
delay(
500
);
}
// Ensure final state
buzzerOFF();
renderFlashFrame(
COL_WHITE
);
// ----------------------------------------------------------
// Re-sync encoder after blocking alarm
// ----------------------------------------------------------
lastRawCLK =
digitalRead(
ENCODER_CLK
);
stableCLK =
lastRawCLK;
clkChangeTime =
millis();
lastClickTime =
millis();
readyForNextClick =
true;
// Touch state reset
touchWasDown =
false;
rangeTouchCandidate =
false;
}
// ============================================================
// COUNTDOWN
// ============================================================
void updateTimer()
{
if (!timerRunning)
{
return;
}
unsigned long now =
millis();
if (
now -
lastSecondMillis
>=
1000UL
)
{
lastSecondMillis +=
1000UL;
if (remainingSeconds > 0)
{
remainingSeconds--;
renderTimerFrame();
}
// ========================================================
// FINISHED
// ========================================================
if (remainingSeconds <= 0)
{
remainingSeconds =
0;
timerRunning =
false;
countdownHasStarted =
false;
Serial.println(
"TIMER FINISHED"
);
flashFinished();
}
}
}
// ============================================================
// DISPLAY INIT
// ============================================================
void initDisplay()
{
pinMode(
BL_PIN,
OUTPUT
);
digitalWrite(
BL_PIN,
HIGH
);
panelBus =
new Arduino_SWSPI(
GFX_NOT_DEFINED,
PANEL_CS,
PANEL_SCK,
PANEL_SDA,
GFX_NOT_DEFINED
);
rgbpanel =
new Arduino_ESP32RGBPanel(
40, 7, 15, 41,
46, 3, 8, 18, 17,
14, 13, 12, 11, 10, 9,
5, 45, 48, 47, 21,
1, 50, 10, 50,
1, 30, 10, 30,
PCLK_NEG,
8000000UL
);
display =
new Arduino_RGB_Display(
DISPLAY_WIDTH,
DISPLAY_HEIGHT,
rgbpanel,
0,
true,
panelBus,
GFX_NOT_DEFINED,
st7701_type7_init_operations,
sizeof(
st7701_type7_init_operations
)
);
// ----------------------------------------------------------
// Full screen Canvas
// ----------------------------------------------------------
gfx =
new Arduino_Canvas(
DISPLAY_WIDTH,
DISPLAY_HEIGHT,
display
);
bool ok =
gfx->begin(
16000000
);
if (!ok)
{
Serial.println(
"ERROR: CANVAS allocation failed!"
);
while (true)
{
delay(
1000
);
}
}
}
// ============================================================
// I2C + TOUCH + PCF INIT
// ============================================================
void initTouchAndIO()
{
Wire.begin(
I2C_SDA,
I2C_SCL
);
// PCF8574 is a standard-mode device.
// CST8XX also works correctly at 100 kHz.
Wire.setClock(
100000
);
// ----------------------------------------------------------
// External PCF:
// all pins HIGH
// buzzer therefore OFF
// ----------------------------------------------------------
externalPcfState =
0xFF;
externalPcfWrite();
// ----------------------------------------------------------
// Touch reset using INTERNAL PCF8574
// ----------------------------------------------------------
resetTouch();
// ----------------------------------------------------------
// Touch initialize
// ----------------------------------------------------------
if (
!touch.begin(
&Wire,
TOUCH_ADDR
)
)
{
Serial.println(
"ERROR: CST8XX TOUCH NOT FOUND"
);
while (true)
{
delay(
1000
);
}
}
Serial.println(
"TOUCH: OK"
);
Serial.println(
"EXTERNAL PCF8574: 0x20"
);
Serial.println(
"BUZZER: READY"
);
}
// ============================================================
// SETUP
// ============================================================
void setup()
{
Serial.begin(
115200
);
// ----------------------------------------------------------
// Encoder
// ----------------------------------------------------------
pinMode(
ENCODER_CLK,
INPUT_PULLUP
);
pinMode(
ENCODER_DT,
INPUT_PULLUP
);
lastRawCLK =
digitalRead(
ENCODER_CLK
);
stableCLK =
lastRawCLK;
// ----------------------------------------------------------
// Display
// ----------------------------------------------------------
initDisplay();
// ----------------------------------------------------------
// Touch + PCF8574 + buzzer
// ----------------------------------------------------------
initTouchAndIO();
delay(
300
);
// ----------------------------------------------------------
// Initial screen
// ----------------------------------------------------------
remainingSeconds =
0;
buzzerOFF();
renderTimerFrame();
Serial.println();
Serial.println(
"================================"
);
Serial.println(
"TIMER V1.0 FINAL READY"
);
Serial.println(
"================================"
);
Serial.println(
"1 MIN = YELLOW"
);
Serial.println(
"10 MIN = TURQUOISE"
);
Serial.println(
"30 MIN = ORANGE"
);
Serial.println(
"60 MIN = MAGENTA"
);
Serial.println();
Serial.println(
"SHORT CLICK = START / STOP"
);
Serial.println(
"LONG CLICK = RESET"
);
Serial.println(
"ALARM = 3 x RED + BEEP"
);
}
// ============================================================
// LOOP
// ============================================================
void loop()
{
// Encoder first
handleEncoder();
// Physical encoder button
handleButton();
// Touch range selector
handleTouch();
// Countdown
updateTimer();
delay(
1
);
}
