I Made a Nice Analog Watch Display Which is Accurate At All Times

Meet “Saiko”, the IoTomatic watch

Alan Wang
20 min readApr 18, 2024

Long story short — this device is a small round full-color LCD screen attached to a microcontroller, which connects to Wi-Fi and update the time from NTP (Network Time Protocol) server every hour. You can find the full technical details, wiring diagram and open-sourced code (Arduino C++) in the Hackster post above.

The watch is named “Saiko(u)” (Japanese さいこう, superb/the best), which is intended as a pun for Seiko, since the watch face design is based on the Seiko 5 Sports SRPK33K1 model. I don’t own one but I do have a black Seiko 5 Sports flieger, which uses the same 4R36 caliber.

The diver-styled watch face is drawn and updated every 1/6 seconds. Almost all elements are drawn live with code using basic shapes. Needless to say, the hour and minute hands are the most tricky part.

The design for how to draw various parts of the watch face

Since the screen resolution is not that high, angled lines cannot be drawn accurately. Still, it looks good enough from an arm’s length.

The second hand on Saiko also moves 6 times per second like the 4R36 movement (which operates at 3 Hz or 6 vibrations per second). However, I decided not to recreate the date rolling feature, since this will be way too complicated and not necessary on a fake mechanical watch.

To complete the illusion of a watch, the LCD is placed on a cushion in a paper gift box, and have steel bracelets (from one of my dad’s cheap watch) tied around it. The whole setup — with the microcontroller tucked behind the gift box — is stuffed into a wooden box with glass panels. It actually looks like a real watch from some distance away.

This currently sits on my office desk as my work clock. I think it’s the best looking Internet clock I’ve ever made… 😊

secrets.h

#define SECRET_SSID "your-wifi-id"
#define SECRET_PASS "your-wifi-password"

Main script

#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"
*/

#define DEMO_MODE false // demo display mode
#define BENCHMARK false // print drawing time (ms) into serial port
#define BAUD_RATE 115200 // serial port baud rate
#define CONNECT_TIMEOUT 15 // 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 8 // timezone offset (hours)

#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
#define TFT_HEIGHT 240
#define TFT_ROTATION 0

// 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

#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

#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

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);

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;

// '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
};

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;

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() {
//setCpuFrequencyMhz(160);

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
uint8_t count = 0;
bool startup_switch = true;
delay(500);
Serial.print("Connecting to WiFi \"");
Serial.print(SECRET_SSID);
Serial.println("\"...");
tft.setFont(&FreeMonoBold9pt7b);
tft.setTextSize(1);
tft.getTextBounds("Connecting WiFi...",
0,
0,
&text_x,
&text_y,
&text_w,
&text_h);
WiFi.disconnect();
WiFi.begin(ssid, pass);
while (WiFi.status() != WL_CONNECTED) {
if (count >= CONNECT_TIMEOUT * 2) ESP.restart();
else count++;
Serial.print(".");
if (startup_switch) tft.setTextColor(GC9A01A_WHITE);
else tft.setTextColor(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.println("\nConnected. IP:");
Serial.println(WiFi.localIP());
timeClient.begin();
}

drawInitial();
}

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

if (!DEMO_MODE) {
timeClient.update();
getTime();
}

drawDial();

if (BENCHMARK) {
Serial.print(millis() - t);
Serial.println(" ms");
}

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

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

if (second != second_prev) {
second_vibrate_count = 0;
time_t rawtime = timeClient.getEpochTime();
struct tm *ti = localtime(&rawtime);
day = ti->tm_mday;
hour = ti->tm_hour;
minute = ti->tm_min;
weekday = weekDays[ti->tm_wday];
} 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);
}

--

--

Alan Wang

Technical writer, former translator and IT editor.