Welcome to Hackster!
Hackster is a community dedicated to learning hardware, from beginner to pro. Join us, it's free!
Alan Wang
Published © CC BY-NC-SA

"Saiko" IoTomatic Watch

Create a analog-styled IoT watch display with ESP32 and GC9A01A TFT LCD

BeginnerFull instructions provided528
"Saiko" IoTomatic Watch

Things used in this project

Hardware components

ESP32-PICO-D4 Module
Espressif ESP32-PICO-D4 Module
×1
GC9A01A round RGB TFT LCD module
×1
Breadboard (generic)
Breadboard (generic)
×1
Jumper wires (generic)
Jumper wires (generic)
×1

Software apps and online services

Arduino IDE
Arduino IDE

Story

Read more

Schematics

"Saiko" IoTomatic Watch

Code

"Saiko" IoTomatic Watch

C/C++
#include "secrets.h"  // imports SECRET_SSID and SECRET_PASS

/*
Create a <secrets.h> in the same project:

#define SECRET_SSID "your_WiFi_ssid"
#define SECRET_PASS "your_WiFi_password"
*/

// user configuration

#define DEMO_MODE false     // demo display mode (fixed time, will not connect wifi)
#define BENCHMARK false     // print dial drawing time (ms) into serial port
#define BAUD_RATE 115200    // serial port baud rate
#define CONNECT_TIMEOUT 30  // WiFi connection timeout (seconds)

#define NTP_SERVER "pool.ntp.org"  // NTP server
#define NTP_INTERVAL 60            // interval for querying NTP server (minutes)
#define NTP_HOUR_OFFSET 0          // timezone offset (hours; 1 = +1, -1 = -1)

#define TFT_CS 5        // GC9A01A CS pin
#define TFT_DC 21       // GC9A01A DC pin
#define TFT_RST 22      // GC9A01A reset pin
#define TFT_WIDTH 240   // GC9A01A width
#define TFT_HEIGHT 240  // GC9A01A height
#define TFT_ROTATION 0  // GC9A01A rotation (0~3)

// import dependencies

#include <math.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <NTPClient.h>
#include <Adafruit_GFX.h>
#include <Adafruit_GC9A01A.h>
#include <Fonts/FreeMonoBold9pt7b.h>
#include <Fonts/FreeSerifItalic9pt7b.h>
#include <Fonts/TomThumb.h>

// configuration for the watch face

// dial specs

#define DIAL_WIDTH 192
#define DIAL_HEIGHT 192
#define BEZEL_TICK_W 6
#define BEZEL_TICK_PAD 5
#define BEZEL_NOON_PAD 5
#define BEZEL_DOT_SELF_R 2
#define BEZEL_DOT_POS_R_P 0.3
#define DIAL_RING_W 10
#define INNER_TICK_W 6
#define INNER_TICK_NOON_W 6
#define INNER_TICK_PAD 3
#define ROUND_DOT_POS_R_P 0.84
#define ROUND_DOT_SELF_R 6
#define ROUND_DOT_BORDER_W 2
#define LOGO_NAME "SAIKO"
#define LOGO_POS_R_P 0.35
#define DESCRIPTION "IoTomatic"
#define DESCRIPTION_POS_R_P 0.38
#define DATE_W 18
#define DATE_H 14
#define WEEK_W 28
#define WEEK_H 14
#define HANDS_AXIS_R 1
#define HOUR_MIN_HAND_BORDER 2
#define HOUR_MIN_HAND_POINT_W 3
#define HOUR_MIN_HAND_POINT_H 4
#define HOUR_HAND_A_W 16
#define HOUR_HAND_A_H 6
#define HOUR_HAND_B_ALT 8
#define HOUR_HAND_C_W 24
#define HOUR_HAND_C_H 12
#define HOUR_HAND_D_BASE 16
#define HOUR_HAND_D_ALT 10
#define MIN_HAND_A_ALT 6
#define MIN_HAND_B_W 18
#define MIN_HAND_B_H 18
#define MIN_HAND_B_OPPS_R 8
#define MIN_HAND_C_W 14
#define MIN_HAND_C_H 14
#define MIN_HAND_D_W 30
#define MIN_HAND_D_H 8
#define MIN_HAND_E_BASE 24
#define MIN_HAND_E_ALT 22
#define MIN_HAND_F_ALT 8
#define SECOND_HAND_VIBRATION 6  // 3 Hz for second hand
#define SECOND_HAND_R_P 0.92
#define SECOND_HAND_BALANCE_R_P 0.42
#define SECOND_HAND_BALANCE_DOT_SELF_R 5
#define SECOND_HAND_BALANCE_DOT_BORDER_W 1
#define SECOND_HAND_BASE_R 5

// dial colors

#define BEZEL_COLOR 0x4E4B47
#define DIAL_COLOR 0x0ABAB5
#define DIAL_RING_COLOR 0x089E9A
#define BEZEL_TICK_COLOR 0xD3D3D3
#define INNER_TICK_COLOR 0x4E4B47
#define ROUND_DOT_COLOR 0xFAEED8
#define ROUND_DOT_BORDER 0x6A6B6E
#define LOGO_COLOR 0x56534E
#define DESCRIPTION_COLOR 0x675b5A
#define DATE_COLOR 0x4E4B47
#define DATE_SUN_COLOR 0xD90000
#define HOUR_MIN_HAND_BORDER_COLOR 0xBE8A0B
#define HOUR_MIN_HAND_FRONT_COLOR 0xFAEED8
#define SECOND_HAND_COLOR 0x373130
#define SECOND_HAND_BALANCE_COLOR 0xFAEED8
#define SECOND_HAND_AXIS_COLOR 0xB5B6B8

// calculated specs

const uint16_t TFT_CENTER_X = round(TFT_WIDTH / 2);
const uint16_t TFT_CENTER_Y = round(TFT_HEIGHT / 2);
const uint16_t TFT_R = TFT_CENTER_X < TFT_CENTER_Y ? TFT_CENTER_X : TFT_CENTER_Y;
const uint16_t DIAL_CENTER_X = round(DIAL_WIDTH / 2);
const uint16_t DIAL_CENTER_Y = round(DIAL_HEIGHT / 2);
const uint16_t DIAL_RING_R = DIAL_CENTER_X < DIAL_CENTER_Y ? DIAL_CENTER_X : DIAL_CENTER_Y;
const uint16_t BEZEL_R = TFT_R;
const uint16_t BEZEL_W = TFT_R - DIAL_RING_R;
const uint16_t DIAL_INNER_R = DIAL_RING_R - DIAL_RING_W;
const uint16_t ROUND_DOT_R = round(DIAL_INNER_R * ROUND_DOT_POS_R_P);

// '10' on bezel ring, 23x26px
const unsigned char bitmap_bezel_10[] PROGMEM = {
  0xff, 0x7f, 0xfe, 0xfe, 0x07, 0xfe, 0xfe, 0x03, 0xfe, 0xfe, 0x01, 0xfe, 0xff, 0x81, 0xfe, 0xfe,
  0x01, 0xfe, 0xf8, 0x07, 0xfe, 0xe0, 0x1f, 0xfe, 0xc0, 0x7f, 0xfe, 0x80, 0xff, 0xfe, 0x83, 0xff,
  0xfe, 0xcf, 0xfe, 0x1e, 0xff, 0xf8, 0x0e, 0xff, 0xe0, 0x06, 0xff, 0xc0, 0x02, 0xff, 0x01, 0xc2,
  0xfe, 0x03, 0xe0, 0xfe, 0x0f, 0xe0, 0xfe, 0x3f, 0xe0, 0xfe, 0x3f, 0x80, 0xfe, 0x1f, 0x02, 0xfe,
  0x0c, 0x06, 0xff, 0x00, 0x0e, 0xff, 0x80, 0x3e, 0xff, 0x80, 0xfe, 0xff, 0xe1, 0xfe
};

// '20' on bezel ring, 25x31px
const unsigned char bitmap_bezel_20[] PROGMEM = {
  0xff, 0xff, 0xff, 0x80, 0xff, 0xf3, 0xff, 0x80, 0xff, 0xe0, 0xff, 0x80, 0xff, 0xe0, 0x7f, 0x80,
  0xff, 0xc0, 0x3f, 0x80, 0xff, 0xc0, 0x37, 0x80, 0xff, 0x84, 0x31, 0x80, 0xff, 0x84, 0x20, 0x80,
  0xff, 0x0c, 0x20, 0x80, 0xfe, 0x0c, 0x38, 0x00, 0xfe, 0x18, 0x78, 0x80, 0xff, 0x38, 0x70, 0x80,
  0xff, 0xf8, 0x60, 0x80, 0xff, 0xf8, 0x01, 0x80, 0xff, 0xfc, 0x03, 0x80, 0xf8, 0xfe, 0x03, 0x80,
  0xe0, 0x3f, 0x87, 0x80, 0xe0, 0x0f, 0xff, 0x80, 0xc0, 0x03, 0xff, 0x80, 0x83, 0x01, 0xff, 0x80,
  0x87, 0x80, 0xff, 0x80, 0x8f, 0xe0, 0x7f, 0x80, 0x8f, 0xf8, 0x7f, 0x80, 0x83, 0xf8, 0x7f, 0x80,
  0x81, 0xf8, 0x7f, 0x80, 0xc0, 0x70, 0xff, 0x80, 0xe0, 0x00, 0xff, 0x80, 0xf8, 0x01, 0xff, 0x80,
  0xfe, 0x03, 0xff, 0x80, 0xff, 0x07, 0xff, 0x80, 0xff, 0xff, 0xff, 0x80
};

// '30' on bezel ring, 29x16px
const unsigned char bitmap_bezel_30[] PROGMEM = {
  0xe0, 0x1f, 0xc0, 0x38, 0xc0, 0x0f, 0x80, 0x18, 0x80, 0x0f, 0x00, 0x08, 0x80, 0x07, 0x00, 0x08,
  0x87, 0x87, 0x0f, 0x08, 0x87, 0x87, 0x0f, 0x08, 0x87, 0x87, 0x00, 0x88, 0x87, 0x87, 0x80, 0xf8,
  0x87, 0x87, 0x80, 0xf8, 0x87, 0x87, 0x00, 0x18, 0x87, 0x87, 0x0f, 0x08, 0x87, 0x87, 0x0f, 0x08,
  0x80, 0x07, 0x00, 0x08, 0x80, 0x0f, 0x80, 0x18, 0xc0, 0x0f, 0x80, 0x18, 0xf0, 0x7f, 0xe0, 0xf8
};

// '40' on bezel ring, 23x27px
const unsigned char bitmap_bezel_40[] PROGMEM = {
  0xff, 0xff, 0xfe, 0xff, 0x07, 0xfe, 0xfc, 0x03, 0xfe, 0xf0, 0x01, 0xfe, 0xe0, 0x21, 0xfe, 0x80,
  0x70, 0xfe, 0x81, 0xf0, 0xfe, 0x07, 0xf8, 0x7e, 0x0f, 0xf0, 0x7e, 0x0f, 0xc0, 0xfe, 0x87, 0x80,
  0xfe, 0x86, 0x01, 0xfe, 0xc0, 0x07, 0xfe, 0xe0, 0x1f, 0xfe, 0xe0, 0x3e, 0x66, 0xf9, 0xfc, 0x02,
  0xff, 0xfc, 0x02, 0xff, 0xfc, 0x06, 0xff, 0xf0, 0x0e, 0xff, 0xc0, 0x0e, 0xff, 0x80, 0x86, 0xff,
  0x01, 0x86, 0xff, 0x00, 0x02, 0xff, 0x80, 0x00, 0xff, 0x80, 0x00, 0xff, 0xff, 0x06, 0xff, 0xff,
  0xfe
};

// '50' on bezel ring, 26x30px
const unsigned char bitmap_bezel_50[] PROGMEM = {
  0xff, 0xff, 0xff, 0xc0, 0xff, 0xf8, 0x7f, 0xc0, 0xff, 0xf0, 0x1f, 0xc0, 0xff, 0xe0, 0x07, 0xc0,
  0xff, 0xc0, 0x03, 0xc0, 0xff, 0xc3, 0x80, 0xc0, 0xff, 0x87, 0xc0, 0x40, 0xff, 0x87, 0xf0, 0x40,
  0xff, 0x87, 0xf8, 0x40, 0xff, 0x81, 0xf8, 0x40, 0xff, 0xc0, 0xf8, 0x40, 0xff, 0xe0, 0x30, 0xc0,
  0xff, 0xf0, 0x00, 0xc0, 0xff, 0xfc, 0x01, 0xc0, 0xf9, 0xff, 0x03, 0xc0, 0xf0, 0xff, 0x87, 0xc0,
  0xf0, 0xdf, 0xff, 0xc0, 0xe1, 0x03, 0xff, 0xc0, 0xc0, 0x01, 0xff, 0xc0, 0xc2, 0x00, 0x7f, 0xc0,
  0x84, 0x20, 0x7f, 0xc0, 0x80, 0x38, 0x7f, 0xc0, 0x00, 0x7c, 0x7f, 0xc0, 0x80, 0x78, 0x7f, 0xc0,
  0xe0, 0x78, 0x7f, 0xc0, 0xf8, 0x00, 0xff, 0xc0, 0xfc, 0x00, 0xff, 0xc0, 0xff, 0x01, 0xff, 0xc0,
  0xff, 0x83, 0xff, 0xc0, 0xff, 0xff, 0xff, 0xc0
};

const uint8_t BEAT_DELAY = round(1000 / SECOND_HAND_VIBRATION);
const uint8_t SECOND_DELTA_DEGREE = round(6 / SECOND_HAND_VIBRATION);

const String weekDays[7] = { "SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT" };
const char ssid[] = SECRET_SSID;
const char pass[] = SECRET_PASS;

// internal variables

String weekday = weekDays[0];
uint8_t day = 31;
uint8_t hour = 10;
uint8_t minute = 9;
uint8_t second = 42;
uint8_t second_prev = 42;
uint8_t second_vibrate_count = 0;
unsigned long t;
int16_t text_x, text_y;
uint16_t text_w, text_h;
bool firstTimeUpdated = false;
bool firstDialDrawn = false;

// functionalities and devices

Adafruit_GC9A01A tft(TFT_CS, TFT_DC, TFT_RST);
GFXcanvas16 canvas(DIAL_WIDTH, DIAL_HEIGHT);  // buffer
WiFiUDP ntpUDP;
NTPClient timeClient(
  ntpUDP,
  NTP_SERVER,
  NTP_HOUR_OFFSET * 60 * 60,
  NTP_INTERVAL * 60 * 1000);

void setup() {
  // set ESP32 freq to save power
  setCpuFrequencyMhz(80);
  
  Serial.begin(BAUD_RATE);

  // initialize screen and buffer
  tft.begin();
  tft.setRotation(TFT_ROTATION);
  tft.fillScreen(GC9A01A_BLACK);
  tft.fillScreen(GC9A01A_BLACK);
  tft.setTextWrap(false);
  tft.cp437(true);
  canvas.fillScreen(GC9A01A_BLACK);
  canvas.setTextWrap(false);
  canvas.cp437(true);

  if (!DEMO_MODE) {
    // connect to WiFi and initialize NTP client
    delay(500);
    Serial.printf("Connecting to WiFi '%s'...\n", SECRET_SSID);

    tft.setFont(&FreeMonoBold9pt7b);
    tft.setTextSize(1);
    tft.getTextBounds("Connecting WiFi...",
                      0,
                      0,
                      &text_x,
                      &text_y,
                      &text_w,
                      &text_h);

    uint8_t count = 0;
    bool startup_switch = true;
    WiFi.disconnect();
    WiFi.begin(ssid, pass);
    while (WiFi.status() != WL_CONNECTED * 2) {
      if (count >= CONNECT_TIMEOUT) ESP.restart();

      count++;
      Serial.print(".");

      if (startup_switch)
        tft.setTextColor(startup_switch ? GC9A01A_WHITE : GC9A01A_BLACK);

      tft.setCursor(round(TFT_CENTER_X - text_w / 2),
                    round(TFT_CENTER_Y - text_h / 2));
      tft.print("Connecting WiFi...");

      startup_switch = !startup_switch;
      delay(500);
    }

    Serial.printf("\nConnected.\n[IP] %s\n", WiFi.localIP().toString());
    timeClient.begin();
  }

  drawInitial();
}

// main loop
void loop() {
  t = millis();

  if (!DEMO_MODE) getTime();

  if (!DEMO_MODE || !firstDialDrawn) {
    drawDial();
    if (BENCHMARK) Serial.printf("[benchmark] dial drawing: %d ms\n", millis() - t);
    firstDialDrawn = true;
    if (DEMO_MODE) Serial.println("[demo mode]");
  }

  while ((millis() - t) < BEAT_DELAY)
    ;
}

// read date and time from NTP client
void getTime() {
  second = timeClient.getSeconds();
  if (second >= 60) second = 0;

  // update time every second
  if (second != second_prev) {
    second_vibrate_count = 0;

    time_t rawtime = timeClient.getEpochTime();
    struct tm *ti = localtime(&rawtime);

    int year = ti->tm_year + 1900;
    int month = ti->tm_mon + 1;
    day = ti->tm_mday;
    hour = ti->tm_hour;
    minute = ti->tm_min;
    weekday = weekDays[ti->tm_wday];

    Serial.printf("[time] %4d-%02d-%02d (%s) %02d:%02d:%02d\n", year, month, day, weekday, hour, minute, second);

    // try update NTP client every minute
    if (second == 0 || !firstTimeUpdated) {
      if (timeClient.update()) {
        Serial.println("[clock] NTP time synced");
        if (!firstTimeUpdated) firstTimeUpdated = true;
      }
    }
  } else {
    if (second_vibrate_count < SECOND_HAND_VIBRATION - 1)
      second_vibrate_count += SECOND_DELTA_DEGREE;
  }

  second_prev = second;
}

// draw the initial parts that do not require redrawing
void drawInitial() {
  // diver bezel ring
  tft.fillCircle(TFT_CENTER_X,
                 TFT_CENTER_Y,
                 BEZEL_R,
                 RGB565(BEZEL_COLOR));
  canvas.fillCircle(DIAL_CENTER_X,
                    DIAL_CENTER_Y,
                    BEZEL_R,
                    RGB565(BEZEL_COLOR));

  // ticks on diver bezel ring
  for (uint8_t tick_pos = 5; tick_pos <= 55; tick_pos += 10) {
    drawAngledBox(tft,
                  TFT_CENTER_X,
                  TFT_CENTER_Y,
                  DIAL_RING_R + BEZEL_TICK_PAD,
                  BEZEL_TICK_W, BEZEL_W - BEZEL_TICK_PAD * 2,
                  handToDegree(tick_pos, false),
                  RGB565(BEZEL_TICK_COLOR));
    drawAngledBox(canvas,
                  DIAL_CENTER_X,
                  DIAL_CENTER_Y,
                  DIAL_RING_R + BEZEL_TICK_PAD,
                  BEZEL_TICK_W, BEZEL_W - BEZEL_TICK_PAD * 2,
                  handToDegree(tick_pos, false),
                  RGB565(BEZEL_TICK_COLOR));
  }

  // bezel noon (triangle on 12 o'clock)
  const uint16_t x_noon = TFT_CENTER_X;
  const uint16_t y_noon = TFT_CENTER_Y - DIAL_RING_R - BEZEL_TICK_PAD;
  const float x_delta = (BEZEL_W - BEZEL_TICK_PAD) * cos(50 * M_PI / 180);
  const float y_delta = (BEZEL_W - BEZEL_TICK_PAD) * sin(50 * M_PI / 180);
  tft.fillTriangle(x_noon,
                   y_noon,
                   round(x_noon + x_delta),
                   round(y_noon - y_delta),
                   round(x_noon - x_delta),
                   round(y_noon - y_delta),
                   RGB565(BEZEL_TICK_COLOR));

  // numbers on bezel ring
  for (uint8_t tick_pos = 10; tick_pos <= 50; tick_pos += 10) {
    const float x_delta = cos(handToDegree(tick_pos, false) * M_PI / 180);
    const float y_delta = sin(handToDegree(tick_pos, false) * M_PI / 180);
    uint8_t w = 0;
    uint8_t h = 0;
    switch (tick_pos) {
      case 10:
        w = 23;
        h = 26;
        tft.drawBitmap(round(TFT_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                       round(TFT_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                       bitmap_bezel_10,
                       w,
                       h,
                       RGB565(BEZEL_COLOR),
                       RGB565(BEZEL_TICK_COLOR));
        canvas.drawBitmap(round(DIAL_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                          round(DIAL_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                          bitmap_bezel_10,
                          w,
                          h,
                          RGB565(BEZEL_COLOR),
                          RGB565(BEZEL_TICK_COLOR));
        break;
      case 20:
        w = 25;
        h = 31;
        tft.drawBitmap(round(TFT_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                       round(TFT_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                       bitmap_bezel_20,
                       w,
                       h,
                       RGB565(BEZEL_COLOR),
                       RGB565(BEZEL_TICK_COLOR));
        canvas.drawBitmap(round(DIAL_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                          round(DIAL_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                          bitmap_bezel_20,
                          w,
                          h,
                          RGB565(BEZEL_COLOR),
                          RGB565(BEZEL_TICK_COLOR));
        break;
      case 30:
        w = 29;
        h = 16;
        tft.drawBitmap(round(TFT_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                       round(TFT_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                       bitmap_bezel_30,
                       w,
                       h,
                       RGB565(BEZEL_COLOR),
                       RGB565(BEZEL_TICK_COLOR));
        canvas.drawBitmap(round(DIAL_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                          round(DIAL_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                          bitmap_bezel_30,
                          w,
                          h,
                          RGB565(BEZEL_COLOR),
                          RGB565(BEZEL_TICK_COLOR));
        break;
      case 40:
        w = 23;
        h = 27;
        tft.drawBitmap(round(TFT_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                       round(TFT_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                       bitmap_bezel_40,
                       w,
                       h,
                       RGB565(BEZEL_COLOR),
                       RGB565(BEZEL_TICK_COLOR));
        canvas.drawBitmap(round(DIAL_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2), round(DIAL_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2), bitmap_bezel_40, w, h, RGB565(BEZEL_COLOR), RGB565(BEZEL_TICK_COLOR));
        break;
      case 50:
        w = 26;
        h = 30;
        tft.drawBitmap(round(TFT_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                       round(TFT_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2),
                       bitmap_bezel_50,
                       w,
                       h,
                       RGB565(BEZEL_COLOR),
                       RGB565(BEZEL_TICK_COLOR));
        canvas.drawBitmap(round(DIAL_CENTER_X + (DIAL_RING_R + BEZEL_W / 2) * x_delta - w / 2),
                          round(DIAL_CENTER_Y + (DIAL_RING_R + BEZEL_W / 2) * y_delta - h / 2), bitmap_bezel_50,
                          w,
                          h,
                          RGB565(BEZEL_COLOR),
                          RGB565(BEZEL_TICK_COLOR));
        break;
    }
  }

  // small dots on bezel ring
  for (uint8_t tick_pos = 1; tick_pos <= 18; tick_pos++) {
    switch (tick_pos) {
      case 5:
      case 9:
      case 10:
      case 11:
      case 15:
        continue;
    }
    const float x_delta = cos(handToDegree(tick_pos, false) * M_PI / 180);
    const float y_delta = sin(handToDegree(tick_pos, false) * M_PI / 180);
    tft.fillCircle(round(TFT_CENTER_X + (DIAL_RING_R + BEZEL_W * BEZEL_DOT_POS_R_P) * x_delta),
                   round(TFT_CENTER_Y + (DIAL_RING_R + BEZEL_W * BEZEL_DOT_POS_R_P) * y_delta),
                   BEZEL_DOT_SELF_R,
                   RGB565(BEZEL_TICK_COLOR));
    canvas.fillCircle(round(DIAL_CENTER_X + (DIAL_RING_R + BEZEL_W * BEZEL_DOT_POS_R_P) * x_delta),
                      round(DIAL_CENTER_Y + (DIAL_RING_R + BEZEL_W * BEZEL_DOT_POS_R_P) * y_delta),
                      BEZEL_DOT_SELF_R,
                      RGB565(BEZEL_TICK_COLOR));
  }

  // dial ring
  canvas.fillCircle(DIAL_CENTER_X,
                    DIAL_CENTER_Y,
                    DIAL_RING_R,
                    RGB565(DIAL_RING_COLOR));

  // second ticks on dial ring
  for (uint8_t tick_pos = 0; tick_pos < 60; tick_pos++) {
    drawLine(canvas,
             DIAL_CENTER_X,
             DIAL_CENTER_Y,
             DIAL_INNER_R + INNER_TICK_PAD,
             DIAL_RING_W - INNER_TICK_PAD * 2,
             handToDegree(tick_pos, false),
             RGB565(INNER_TICK_COLOR));
  }

  // 5-minute ticks on dial ring
  for (uint8_t tick_pos = 5; tick_pos <= 55; tick_pos += 5) {
    drawAngledBox(canvas,
                  DIAL_CENTER_X,
                  DIAL_CENTER_Y,
                  DIAL_INNER_R + INNER_TICK_PAD,
                  INNER_TICK_W,
                  DIAL_RING_W - INNER_TICK_PAD * 2,
                  handToDegree(tick_pos, false),
                  RGB565(INNER_TICK_COLOR));
  }

  // the thicker tick on dial ring's 12 o'clock
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                DIAL_INNER_R + INNER_TICK_PAD,
                INNER_TICK_NOON_W,
                DIAL_RING_W - INNER_TICK_PAD * 2,
                handToDegree(0, false),
                RGB565(INNER_TICK_COLOR));

  // dial face
  canvas.fillCircle(DIAL_CENTER_X,
                    DIAL_CENTER_Y,
                    DIAL_INNER_R,
                    RGB565(DIAL_COLOR));
}


void drawDial() {
  float x_delta;
  float y_delta;

  // dial face
  canvas.fillCircle(DIAL_CENTER_X,
                    DIAL_CENTER_Y,
                    DIAL_INNER_R,
                    RGB565(DIAL_COLOR));

  for (uint8_t tick_pos = 0; tick_pos <= 55; tick_pos += 5) {
    switch (tick_pos) {
      case 0:  // face noon (triangle) at 12 o'clock
        canvas.fillTriangle(DIAL_CENTER_X,
                            round(DIAL_CENTER_Y - ROUND_DOT_R + ROUND_DOT_SELF_R * 2.5 + ROUND_DOT_BORDER_W * 2),
                            round(DIAL_CENTER_X - ROUND_DOT_SELF_R * 2 - ROUND_DOT_BORDER_W),
                            DIAL_CENTER_Y - ROUND_DOT_R - ROUND_DOT_SELF_R - ROUND_DOT_BORDER_W,
                            round(DIAL_CENTER_X + ROUND_DOT_SELF_R * 2 + ROUND_DOT_BORDER_W),
                            DIAL_CENTER_Y - ROUND_DOT_R - ROUND_DOT_SELF_R - ROUND_DOT_BORDER_W,
                            RGB565(ROUND_DOT_BORDER));
        canvas.drawLine(DIAL_CENTER_X,
                        round(DIAL_CENTER_Y - ROUND_DOT_R + ROUND_DOT_SELF_R * 2.5 + ROUND_DOT_BORDER_W * 2),
                        DIAL_CENTER_X,
                        round(DIAL_CENTER_Y - ROUND_DOT_R + ROUND_DOT_SELF_R * (2.5 + 2)),
                        RGB565(ROUND_DOT_BORDER));
        canvas.fillTriangle(DIAL_CENTER_X,
                            round(DIAL_CENTER_Y - ROUND_DOT_R + ROUND_DOT_SELF_R * 2.5),
                            round(DIAL_CENTER_X - ROUND_DOT_SELF_R * 2),
                            DIAL_CENTER_Y - ROUND_DOT_R - ROUND_DOT_SELF_R,
                            round(DIAL_CENTER_X + ROUND_DOT_SELF_R * 2), DIAL_CENTER_Y - ROUND_DOT_R - ROUND_DOT_SELF_R,
                            RGB565(ROUND_DOT_COLOR));
        continue;
      case 15:  // date and week at 3 o'clock
        canvas.fillRect(round(DIAL_CENTER_X + ROUND_DOT_R - DATE_W / 2 - 1),
                        round(DIAL_CENTER_Y - DATE_H / 2 - 1),
                        DATE_W + 2,
                        DATE_H + 2,
                        RGB565(ROUND_DOT_BORDER));
        canvas.fillRect(round(DIAL_CENTER_X + ROUND_DOT_R - DATE_W / 2 - WEEK_W - 2),
                        round(DIAL_CENTER_Y - WEEK_H / 2 - 1),
                        WEEK_W + 2,
                        WEEK_H + 2,
                        RGB565(ROUND_DOT_BORDER));
        canvas.fillRect(round(DIAL_CENTER_X + ROUND_DOT_R - DATE_W / 2),
                        round(DIAL_CENTER_Y - DATE_H / 2),
                        DATE_W,
                        DATE_H,
                        RGB565(ROUND_DOT_COLOR));
        canvas.fillRect(round(DIAL_CENTER_X + ROUND_DOT_R - DATE_W / 2 - WEEK_W - 1),
                        round(DIAL_CENTER_Y - WEEK_H / 2),
                        WEEK_W,
                        WEEK_H,
                        RGB565(ROUND_DOT_COLOR));
        canvas.setFont(&TomThumb);
        canvas.setTextSize(2);
        canvas.setTextColor(RGB565(DATE_COLOR));
        canvas.getTextBounds(String(day),
                             0,
                             0,
                             &text_x,
                             &text_y,
                             &text_w,
                             &text_h);
        canvas.setCursor(round(DIAL_CENTER_X + ROUND_DOT_R - text_w / 2),
                         round(DIAL_CENTER_Y + text_h / 2));
        canvas.print(String(day));
        if (weekday == weekDays[0]) canvas.setTextColor(RGB565(DATE_SUN_COLOR));
        canvas.getTextBounds(weekday,
                             0,
                             0,
                             &text_x,
                             &text_y,
                             &text_w,
                             &text_h);
        canvas.setCursor(round(DIAL_CENTER_X + ROUND_DOT_R - DATE_W / 2 - WEEK_W - 1 + WEEK_W / 2 - text_w / 2),
                         round(DIAL_CENTER_Y + text_h / 2));
        canvas.print(weekday);
        continue;
      case 30:  // longer round dot at 6 o'clock
        canvas.fillRoundRect(DIAL_CENTER_X - ROUND_DOT_SELF_R - ROUND_DOT_BORDER_W,
                             DIAL_CENTER_Y + ROUND_DOT_R - ROUND_DOT_SELF_R * 2 - ROUND_DOT_BORDER_W,
                             ROUND_DOT_SELF_R * 2 + ROUND_DOT_BORDER_W * 2, ROUND_DOT_SELF_R * 4 + ROUND_DOT_BORDER_W * 2,
                             ROUND_DOT_SELF_R + ROUND_DOT_BORDER_W,
                             RGB565(ROUND_DOT_BORDER));
        canvas.drawLine(DIAL_CENTER_X,
                        DIAL_CENTER_Y + ROUND_DOT_R - ROUND_DOT_SELF_R * 2,
                        DIAL_CENTER_X,
                        DIAL_CENTER_Y + ROUND_DOT_R - ROUND_DOT_SELF_R * 4,
                        RGB565(ROUND_DOT_BORDER));
        canvas.fillRoundRect(DIAL_CENTER_X - ROUND_DOT_SELF_R,
                             DIAL_CENTER_Y + ROUND_DOT_R - ROUND_DOT_SELF_R * 2,
                             ROUND_DOT_SELF_R * 2,
                             ROUND_DOT_SELF_R * 4,
                             ROUND_DOT_SELF_R,
                             RGB565(ROUND_DOT_COLOR));
        continue;
      case 45:  // longer round dot at 9 o'clock
        canvas.fillRoundRect(DIAL_CENTER_X - ROUND_DOT_R - ROUND_DOT_SELF_R * 2 - ROUND_DOT_BORDER_W,
                             DIAL_CENTER_Y - ROUND_DOT_SELF_R - ROUND_DOT_BORDER_W,
                             ROUND_DOT_SELF_R * 4 + ROUND_DOT_BORDER_W * 2,
                             ROUND_DOT_SELF_R * 2 + ROUND_DOT_BORDER_W * 2,
                             ROUND_DOT_SELF_R + ROUND_DOT_BORDER_W,
                             RGB565(ROUND_DOT_BORDER));
        canvas.drawLine(DIAL_CENTER_X - ROUND_DOT_R + ROUND_DOT_SELF_R * 2 + ROUND_DOT_BORDER_W,
                        DIAL_CENTER_Y,
                        DIAL_CENTER_X - ROUND_DOT_R + ROUND_DOT_SELF_R * 4,
                        DIAL_CENTER_Y,
                        RGB565(ROUND_DOT_BORDER));
        canvas.fillRoundRect(DIAL_CENTER_X - ROUND_DOT_R - ROUND_DOT_SELF_R * 2,
                             DIAL_CENTER_Y - ROUND_DOT_SELF_R,
                             ROUND_DOT_SELF_R * 4,
                             ROUND_DOT_SELF_R * 2,
                             ROUND_DOT_SELF_R,
                             RGB565(ROUND_DOT_COLOR));
        continue;
      default:  // the rest of the round dots on the face
        const float x_delta = cos(handToDegree(tick_pos, false) * M_PI / 180);
        const float y_delta = sin(handToDegree(tick_pos, false) * M_PI / 180);
        const uint16_t dot_x = round(ROUND_DOT_R * x_delta);
        const uint16_t dot_y = round(ROUND_DOT_R * y_delta);
        canvas.fillCircle(DIAL_CENTER_X + dot_x,
                          DIAL_CENTER_Y + dot_y,
                          ROUND_DOT_SELF_R + ROUND_DOT_BORDER_W,
                          RGB565(ROUND_DOT_BORDER));
        canvas.fillCircle(DIAL_CENTER_X + dot_x,
                          DIAL_CENTER_Y + dot_y,
                          ROUND_DOT_SELF_R,
                          RGB565(ROUND_DOT_COLOR));
    }
  }

  // logo
  canvas.setFont(&FreeMonoBold9pt7b);
  canvas.setTextSize(1);
  canvas.setTextColor(RGB565(LOGO_COLOR));
  canvas.getTextBounds(LOGO_NAME,
                       0,
                       0,
                       &text_x,
                       &text_y,
                       &text_w,
                       &text_h);
  canvas.setCursor(round(DIAL_CENTER_X - text_w / 2),
                   round(DIAL_CENTER_Y - DIAL_INNER_R * LOGO_POS_R_P + text_h / 2));
  canvas.print(LOGO_NAME);

  // description
  canvas.setFont(&FreeSerifItalic9pt7b);
  canvas.setTextSize(1);
  canvas.setTextColor(RGB565(DESCRIPTION_COLOR));
  canvas.getTextBounds(DESCRIPTION,
                       0,
                       0,
                       &text_x,
                       &text_y,
                       &text_w,
                       &text_h);
  canvas.setCursor(round(DIAL_CENTER_X - text_w / 2),
                   round(DIAL_CENTER_Y + DIAL_INNER_R * DESCRIPTION_POS_R_P + text_h / 2));
  canvas.print(DESCRIPTION);

  // hour hand
  x_delta = cos((handToDegree(hour, true) + 30 * minute / 60) * M_PI / 180);
  y_delta = sin((handToDegree(hour, true) + 30 * minute / 60) * M_PI / 180);
  float x_delta_90 = cos((handToDegree(hour, true) + 30 * minute / 60 + 90) * M_PI / 180);
  float y_delta_90 = sin((handToDegree(hour, true) + 30 * minute / 60 + 90) * M_PI / 180);
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                0,
                HOUR_HAND_A_H,
                HOUR_HAND_A_W,
                handToDegree(hour, true) + 30 * minute / 60,
                RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W - HOUR_HAND_B_ALT) * x_delta),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W - HOUR_HAND_B_ALT) * y_delta),
                      round(DIAL_CENTER_X + HOUR_HAND_A_W * x_delta - HOUR_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + HOUR_HAND_A_W * y_delta - HOUR_HAND_C_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + HOUR_HAND_A_W * x_delta + HOUR_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + HOUR_HAND_A_W * y_delta + HOUR_HAND_C_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta - HOUR_HAND_D_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta - HOUR_HAND_D_BASE / 2 * y_delta_90),
                      round(DIAL_CENTER_X + HOUR_HAND_A_W * x_delta - HOUR_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + HOUR_HAND_A_W * y_delta - HOUR_HAND_C_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + HOUR_HAND_A_W * x_delta + HOUR_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + HOUR_HAND_A_W * y_delta + HOUR_HAND_C_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta - HOUR_HAND_D_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta - HOUR_HAND_D_BASE / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta + HOUR_HAND_D_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta + HOUR_HAND_D_BASE / 2 * y_delta_90),
                      round(DIAL_CENTER_X + HOUR_HAND_A_W * x_delta + HOUR_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + HOUR_HAND_A_W * y_delta + HOUR_HAND_C_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W + HOUR_HAND_D_ALT) * x_delta),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W + HOUR_HAND_D_ALT) * y_delta),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta - HOUR_HAND_D_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta - HOUR_HAND_D_BASE / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta + HOUR_HAND_D_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta + HOUR_HAND_D_BASE / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                HOUR_HAND_A_W + HOUR_HAND_C_W + HOUR_HAND_D_ALT,
                HOUR_MIN_HAND_POINT_W,
                HOUR_MIN_HAND_POINT_H,
                handToDegree(hour, true) + 30 * minute / 60,
                RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta - (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta - (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_MIN_HAND_BORDER) * x_delta - (HOUR_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_MIN_HAND_BORDER) * y_delta - (HOUR_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_MIN_HAND_BORDER) * x_delta + (HOUR_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_MIN_HAND_BORDER) * y_delta + (HOUR_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta - (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta - (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta + (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta + (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_MIN_HAND_BORDER) * x_delta + (HOUR_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_MIN_HAND_BORDER) * y_delta + (HOUR_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W + HOUR_HAND_D_ALT - HOUR_MIN_HAND_BORDER * 2) * x_delta),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W + HOUR_HAND_D_ALT - HOUR_MIN_HAND_BORDER * 2) * y_delta),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta - (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta - (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * y_delta_90),
                      round(DIAL_CENTER_X + (HOUR_HAND_A_W + HOUR_HAND_C_W) * x_delta + (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * x_delta_90),
                      round(DIAL_CENTER_Y + (HOUR_HAND_A_W + HOUR_HAND_C_W) * y_delta + (HOUR_HAND_D_BASE / 2 - HOUR_MIN_HAND_BORDER * 1.5) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));

  // minute hand
  x_delta = cos((handToDegree(minute, false) + 6 * second / 60) * M_PI / 180);
  y_delta = sin((handToDegree(minute, false) + 6 * second / 60) * M_PI / 180);
  x_delta_90 = cos((handToDegree(minute, false) + 6 * second / 60 + 90) * M_PI / 180);
  y_delta_90 = sin((handToDegree(minute, false) + 6 * second / 60 + 90) * M_PI / 180);
  canvas.fillTriangle(round(DIAL_CENTER_X - (MIN_HAND_A_ALT + MIN_HAND_B_OPPS_R) * x_delta),
                      round(DIAL_CENTER_Y - (MIN_HAND_A_ALT + MIN_HAND_B_OPPS_R) * y_delta),
                      round(DIAL_CENTER_X - MIN_HAND_B_OPPS_R * x_delta - MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y - MIN_HAND_B_OPPS_R * y_delta - MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X - MIN_HAND_B_OPPS_R * x_delta + MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y - MIN_HAND_B_OPPS_R * y_delta + MIN_HAND_B_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X - MIN_HAND_B_OPPS_R * x_delta - MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y - MIN_HAND_B_OPPS_R * y_delta - MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X - MIN_HAND_B_OPPS_R * x_delta + MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y - MIN_HAND_B_OPPS_R * y_delta + MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * x_delta - MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * y_delta - MIN_HAND_B_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X - MIN_HAND_B_OPPS_R * x_delta + MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y - MIN_HAND_B_OPPS_R * y_delta + MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * x_delta - MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * y_delta - MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * x_delta + MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * y_delta + MIN_HAND_B_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * x_delta - MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * y_delta - MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * x_delta + MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * y_delta + MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta - MIN_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta - MIN_HAND_C_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * x_delta + MIN_HAND_B_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R) * y_delta + MIN_HAND_B_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta - MIN_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta - MIN_HAND_C_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta + MIN_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta + MIN_HAND_C_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W,
                MIN_HAND_D_H,
                MIN_HAND_D_W,
                handToDegree(minute, false) + 6 * second / 60,
                RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_F_ALT) * x_delta),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_F_ALT) * y_delta),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta - MIN_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta - MIN_HAND_C_H / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta + MIN_HAND_C_H / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta + MIN_HAND_C_H / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + MIN_HAND_E_ALT) * x_delta),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + MIN_HAND_E_ALT) * y_delta),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W) * x_delta - MIN_HAND_E_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W) * y_delta - MIN_HAND_E_BASE / 2 * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W) * x_delta + MIN_HAND_E_BASE / 2 * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W) * y_delta + MIN_HAND_E_BASE / 2 * y_delta_90),
                      RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + MIN_HAND_E_ALT,
                HOUR_MIN_HAND_POINT_W,
                HOUR_MIN_HAND_POINT_H,
                handToDegree(minute, false) + 6 * second / 60,
                RGB565(HOUR_MIN_HAND_BORDER_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + HOUR_MIN_HAND_BORDER) * x_delta - (MIN_HAND_B_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + HOUR_MIN_HAND_BORDER) * y_delta - (MIN_HAND_B_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + HOUR_MIN_HAND_BORDER) * x_delta + (MIN_HAND_B_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + HOUR_MIN_HAND_BORDER) * y_delta + (MIN_HAND_B_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta - (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta - (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + HOUR_MIN_HAND_BORDER) * x_delta + (MIN_HAND_B_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + HOUR_MIN_HAND_BORDER) * y_delta + (MIN_HAND_B_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta - (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta - (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta + (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta + (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_F_ALT - HOUR_MIN_HAND_BORDER * 2) * x_delta),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_F_ALT - HOUR_MIN_HAND_BORDER * 2) * y_delta),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta - (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta - (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * x_delta + (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W) * y_delta + (MIN_HAND_C_H / 2 - HOUR_MIN_HAND_BORDER) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));
  canvas.fillTriangle(round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + MIN_HAND_E_ALT - HOUR_MIN_HAND_BORDER * 2) * x_delta),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + MIN_HAND_E_ALT - HOUR_MIN_HAND_BORDER * 2) * y_delta),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + HOUR_MIN_HAND_BORDER) * x_delta - (MIN_HAND_E_BASE / 2 - HOUR_MIN_HAND_BORDER * 2) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + HOUR_MIN_HAND_BORDER) * y_delta - (MIN_HAND_E_BASE / 2 - HOUR_MIN_HAND_BORDER * 2) * y_delta_90),
                      round(DIAL_CENTER_X + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + HOUR_MIN_HAND_BORDER) * x_delta + (MIN_HAND_E_BASE / 2 - HOUR_MIN_HAND_BORDER * 2) * x_delta_90),
                      round(DIAL_CENTER_Y + (MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W + MIN_HAND_D_W + HOUR_MIN_HAND_BORDER) * y_delta + (MIN_HAND_E_BASE / 2 - HOUR_MIN_HAND_BORDER * 2) * y_delta_90),
                      RGB565(HOUR_MIN_HAND_FRONT_COLOR));
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                MIN_HAND_B_W - MIN_HAND_B_OPPS_R + MIN_HAND_C_W,
                MIN_HAND_D_H - HOUR_MIN_HAND_BORDER * 2,
                MIN_HAND_D_W + HOUR_MIN_HAND_BORDER * 2,
                handToDegree(minute, false) + 6 * second / 60,
                RGB565(HOUR_MIN_HAND_FRONT_COLOR));

  // second hand
  x_delta = cos((handToDegree(second, false) + second_vibrate_count * SECOND_HAND_VIBRATION / 6) * M_PI / 180);
  y_delta = sin((handToDegree(second, false) + second_vibrate_count * SECOND_HAND_VIBRATION / 6) * M_PI / 180);
  drawLine(canvas,
           DIAL_CENTER_X,
           DIAL_CENTER_Y,
           0,
           DIAL_INNER_R * SECOND_HAND_R_P,
           handToDegree(second, false) + second_vibrate_count * SECOND_HAND_VIBRATION / 6,
           RGB565(SECOND_HAND_COLOR));
  drawAngledBox(canvas,
                DIAL_CENTER_X,
                DIAL_CENTER_Y,
                0,
                2,
                DIAL_INNER_R * SECOND_HAND_BALANCE_R_P,
                (handToDegree(second, false) + second_vibrate_count * SECOND_HAND_VIBRATION / 6) + 180,
                RGB565(SECOND_HAND_COLOR));
  canvas.fillCircle(round(DIAL_CENTER_X - DIAL_INNER_R * SECOND_HAND_BALANCE_R_P * x_delta),
                    round(DIAL_CENTER_Y - DIAL_INNER_R * SECOND_HAND_BALANCE_R_P * y_delta),
                    SECOND_HAND_BALANCE_DOT_SELF_R + SECOND_HAND_BALANCE_DOT_BORDER_W,
                    RGB565(SECOND_HAND_COLOR));
  canvas.fillCircle(round(DIAL_CENTER_X - DIAL_INNER_R * SECOND_HAND_BALANCE_R_P * x_delta),
                    round(DIAL_CENTER_Y - DIAL_INNER_R * SECOND_HAND_BALANCE_R_P * y_delta),
                    SECOND_HAND_BALANCE_DOT_SELF_R,
                    RGB565(SECOND_HAND_BALANCE_COLOR));
  canvas.fillCircle(DIAL_CENTER_X,
                    DIAL_CENTER_Y,
                    SECOND_HAND_BASE_R,
                    RGB565(SECOND_HAND_COLOR));
  canvas.fillCircle(DIAL_CENTER_X,
                    DIAL_CENTER_Y,
                    HANDS_AXIS_R,
                    RGB565(SECOND_HAND_AXIS_COLOR));

  // overwrite buffer to screen
  tft.drawRGBBitmap(round((TFT_WIDTH - DIAL_WIDTH) / 2),
                    round((TFT_HEIGHT - DIAL_HEIGHT) / 2),
                    canvas.getBuffer(),
                    canvas.width(),
                    canvas.height());
}

// draw an angled rect box on screen
void drawAngledBox(Adafruit_GC9A01A &canvas, uint8_t center_x, uint8_t center_y, uint8_t r, float w, float h, float angle, uint16_t color) {
  const float x_delta = cos(angle * M_PI / 180);
  const float y_delta = sin(angle * M_PI / 180);
  const float x_side_delta = cos((angle + 90) * M_PI / 180);
  const float y_side_delta = sin((angle + 90) * M_PI / 180);
  const uint16_t x1 = round(center_x + r * x_delta - (w / 2) * x_side_delta);
  const uint16_t x2 = round(center_x + r * x_delta + (w / 2) * x_side_delta);
  const uint16_t x3 = round(center_x + (r + h) * x_delta - (w / 2) * x_side_delta);
  const uint16_t x4 = round(center_x + (r + h) * x_delta + (w / 2) * x_side_delta);
  const uint16_t y1 = round(center_y + r * y_delta - (w / 2) * y_side_delta);
  const uint16_t y2 = round(center_y + r * y_delta + (w / 2) * y_side_delta);
  const uint16_t y3 = round(center_y + (r + h) * y_delta - (w / 2) * y_side_delta);
  const uint16_t y4 = round(center_y + (r + h) * y_delta + (w / 2) * y_side_delta);
  canvas.fillTriangle(x1, y1, x2, y2, x4, y4, color);
  canvas.fillTriangle(x1, y1, x3, y3, x4, y4, color);
}

// draw an angled rect box on buffer
void drawAngledBox(GFXcanvas16 &canvas, uint8_t center_x, uint8_t center_y, uint8_t r, float w, float h, float angle, uint16_t color) {
  const float x_delta = cos(angle * M_PI / 180);
  const float y_delta = sin(angle * M_PI / 180);
  const float x_side_delta = cos((angle + 90) * M_PI / 180);
  const float y_side_delta = sin((angle + 90) * M_PI / 180);
  const uint16_t x1 = round(center_x + r * x_delta - (w / 2) * x_side_delta);
  const uint16_t x2 = round(center_x + r * x_delta + (w / 2) * x_side_delta);
  const uint16_t x3 = round(center_x + (r + h) * x_delta - (w / 2) * x_side_delta);
  const uint16_t x4 = round(center_x + (r + h) * x_delta + (w / 2) * x_side_delta);
  const uint16_t y1 = round(center_y + r * y_delta - (w / 2) * y_side_delta);
  const uint16_t y2 = round(center_y + r * y_delta + (w / 2) * y_side_delta);
  const uint16_t y3 = round(center_y + (r + h) * y_delta - (w / 2) * y_side_delta);
  const uint16_t y4 = round(center_y + (r + h) * y_delta + (w / 2) * y_side_delta);
  canvas.fillTriangle(x1, y1, x2, y2, x4, y4, color);
  canvas.fillTriangle(x1, y1, x3, y3, x4, y4, color);
}

// draw angled line on buffer
void drawLine(GFXcanvas16 &canvas, uint8_t center_x, uint8_t center_y, float r, uint8_t length, int16_t angle, uint16_t color) {
  const float x_delta = cos(angle * M_PI / 180);
  const float y_delta = sin(angle * M_PI / 180);
  canvas.drawLine(round(center_x + r * x_delta),
                  round(center_y + r * y_delta),
                  round(DIAL_CENTER_X + (r + length) * x_delta),
                  round(DIAL_CENTER_Y + (r + length) * y_delta),
                  color);
}

// convert seconds to degree
int16_t handToDegree(int16_t hand, bool hour) {
  if (hour) hand = (hand > 12 ? hand - 12 : hand) * 5;
  return hand * 6 - 90;
}

// convert RGB888 to RGB565
uint16_t RGB565(unsigned long rgb32) {
  return (rgb32 >> 8 & 0xf800) | (rgb32 >> 5 & 0x07e0) | (rgb32 >> 3 & 0x001f);
}

Credits

Alan Wang
31 projects • 103 followers
Please do not ask me for free help for school or company projects. My time is not open sourced and you cannot buy it with free compliments.
Contact

Comments

Please log in or sign up to comment.