Maker.io main logo

DIY ESP32 Universal Timer with Touchscreen & Rotary Encoder

2026-10-26 | By Mirko Pavleski

Share

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.

iuoiu

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.

opko

';]p[][p

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.

l;p[

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.

jkp;jko

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.

klp;[kl

Copy Code
/*==============================================================
   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
  );
}
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.