Story
In this tutorial you will learn how to build a Digital Timer Switch with the CrowPanel 3.5" ESP32 Display. We are going to synchronizing the internal clock with an internet time provider to keep it accurate and use the TFT_eSPI library to build a nice UI that makes it easy to set times. See the demo below.
A Digital Timer Switch is an electronic device that automatically turns other devices on or off at specific times set by the user. It typically has a digital display for programming on/off times. It can be used to control lights, appliances, and other electrical devices at predetermined times. For example, you can use it to turn on lights in your home at sunset and turn them off at bedtime, or to schedule your sprinkler to water your lawn, and then turn them off after a specific duration to avoid overwatering.
The Digital Timer Switch you will build in this tutorial will be better than most commercial digital timer switches. It's time will be always accurate and there will be no need to adjust for daylight savings time. Furthermore, due to the large display it will be easy to set or change schedules.
Create Project Structure
Before going into any details, let's create the project structure and the setup for the TFT_eSPI library.
Open your Arduino IDE and create a project "digital_timer_switch
" and save it (Save As ...). This will create a folder "digital_timer_switch
" with the file "digital_timer_switch.ino
" in it. In this folder create another file named "datestrs.h
" and a third named "tft_setup.h
". Your project folder should look like this
and in your Arduino IDE you should now have three tabs named "digital_timer_switch.ino
", "datestrs.h
" and "tft_setup.h
".
Click on the "tft_setup.h
" tab to open the file and copy the following code into it:
#define ILI9488_DRIVER
#define TFT_HEIGHT 480
#define TFT_WIDTH 320
#define TFT_BACKLIGHT_ON HIGH
#define TFT_BL 27
#define TFT_MISO 12
#define TFT_MOSI 13
#define TFT_SCLK 14
#define TFT_CS 15
#define TFT_DC 2
#define TFT_RST -1
#define TOUCH_CS 33
#define SPI_FREQUENCY 27000000
#define SPI_TOUCH_FREQUENCY 2500000
#define SPI_READ_FREQUENCY 16000000
#define LOAD_GLCD // Font 1. Original Adafruit 8 pixel font needs ~1820 bytes in FLASH
#define LOAD_FONT2 // Font 2. Small 16 pixel high font, needs ~3534 bytes in FLASH, 96 characters
#define LOAD_FONT4 // Font 4. Medium 26 pixel high font, needs ~5848 bytes in FLASH, 96 characters
#define LOAD_FONT6 // Font 6. Large 48 pixel font, needs ~2666 bytes in FLASH, only characters 1234567890:-.apm
#define LOAD_FONT7 // Font 7. 7 segment 48 pixel font, needs ~2438 bytes in FLASH, only characters 1234567890:-.
#define LOAD_FONT8 // Font 8. Large 75 pixel font needs ~3256 bytes in FLASH, only characters 1234567890:-.
#define LOAD_GFXFF // FreeFonts. Include access to the 48 Adafruit_GFX free fonts FF1 to FF48 and custom fonts
This code tells the TFT_eSPI library, which display are we using. Specifically, we let the library know the dimensions of the display (TFT_WIDTH, TFT_WIDTH), its display driver (ILI9488_DRIVER), which SPI pins are used to control it and what fonts to load. Without these settings, you will not be able to show anything on the display.
For more detailed information have a look at the tutorial CrowPanel 2.8" ESP32 Display : Easy Setup Guide. It explains how to setup the 2.8" Display. But the steps and descriptions apply to the 3.5" Display in the same way. Just the settings for the screen dimensions and driver are different.
Calibrating Touchscreen
The CrowPanel 3.5" Display comes with a resistive touch screen that you need to calibrate first, before you can used it with the TFT_eSPI library. Copy the code below into the "digital_timer_switch.ino
" file, compile it and upload it to the CrowPanel 3.5" Display.
#include "tft_setup.h"
#include "TFT_eSPI.h"
TFT_eSPI tft = TFT_eSPI();
void setup() {
Serial.begin(115200);
tft.begin();
tft.setRotation(0);
}
void loop() {
uint16_t cal[5];
tft.fillScreen(TFT_BLACK);
tft.setCursor(20, 0);
tft.setTextFont(2);
tft.setTextSize(1);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.print("Touch corners ... ");
tft.calibrateTouch(cal, TFT_MAGENTA, TFT_BLACK, 15);
tft.println("done.");
Serial.printf("cal: {%d, %d, %d, %d, %d}\n",
cal[0], cal[1], cal[2], cal[3], cal[4]);
delay(10000);
}
When the code is running the displays shows an arrow and tells you to touch the corner it points to. This will be repeated for the other three corners as well. Use the little pen that comes with Display to do this and try to touch the corners as precisely as possible.
At the end of the calibration process the code prints out the 5 calibration parameters (corner coordinates and screen orientation) to the Serial monitor. You should see something like this:
cal: { 260, 3518, 270, 3629, 4 }
Copy the parameters somewhere, since you will need them in the digital timer switch code. The calibration repeats itself every 10 seconds, so you can have several tries to get the most accurate parameters.
For more detailed information on the calibration process have a look at the CrowPanel 2.8" ESP32 Display : Easy Setup Guide tutorial.
Code for Digital Timer Switch
In this section we will write the complete code for the Digital Timer Switch. Start by copying the following code into the "datestrs.h
" file that is in your digital_timer_switch
project folder.
// datestrs.h
const char *DAYSTR[] = {
"Su",
"Mo",
"Tu",
"We",
"Th",
"Fr",
"Sa"
};
const char *MONTHSTR[] = {
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
};
Next copy all of the code below into the "digital_timer_switch.ino
" file and replace the calibration code in there.
This is the main code. It allows users to set alarms based on a weekly schedule, view the current time, and toggle alarms on and off. The display updates in real-time, and the user can interact with it through touch buttons. Just have a quick read and then we will discuss the details of the code.
// digital_timer_switch.ino
#include "tft_setup.h"
#include "TFT_eSPI.h"
#include "TFT_eWidget.h"
#include "stdarg.h"
#include "WiFi.h"
#include "esp_sntp.h"
#include "datestrs.h"
const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* SSID = "SSID";
const char* PWD = "PASSWORD";
const int rs = 24, cs = 7;
const int mx = 60, my = 40;
const int gw = TFT_WIDTH - mx - 30;
const int gh = TFT_HEIGHT - my - 50;
const int cw = gw / cs;
const int ch = gh / rs;
const int alarmPin = 25;
bool schedule[rs][cs];
bool isTimeView = true;
bool isAlarmOn = false;
uint16_t cal[5] = { 260, 3518, 270, 3629, 4 };
TFT_eSPI tft = TFT_eSPI();
ButtonWidget btnView = ButtonWidget(&tft);
ButtonWidget btnAlarm = ButtonWidget(&tft);
ButtonWidget* btns[] = { &btnView, &btnAlarm };
int c2x(int c) {
return mx + gw * c / cs;
}
int r2y(int r) {
return my + gh * r / rs;
}
int x2c(int x) {
return map(x - mx, 0, gw, 0, cs);
}
int y2r(int y) {
return map(y - my, 0, gh, 0, rs);
}
void drawLabels() {
tft.setTextFont(1);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
for (int r = 0; r < rs; r++) {
tft.setCursor(24, r2y(r));
tft.printf("%02d", r);
}
for (int c = 0; c < cs; c++) {
tft.setCursor(c2x(c), 15);
tft.print(DAYSTR[c]);
}
}
void drawSlot(int r, int c) {
uint32_t color = schedule[r][c] ? TFT_WHITE : TFT_BLACK;
tft.fillRect(c2x(c) + 1, r2y(r) + 1, cw - 2, ch - 2, color);
}
void drawSchedule() {
for (int r = 0; r < rs; r++) {
for (int c = 0; c < cs; c++) {
tft.drawRect(c2x(c), r2y(r), cw, ch, TFT_DARKGREY);
drawSlot(r, c);
}
}
}
void selectSlot(int x, int y) {
int c = x2c(x);
int r = y2r(y);
if (c < 0 || c >= cs || r < 0 || r >= rs) return;
schedule[r][c] = !schedule[r][c];
drawSlot(r, c);
}
void initWiFi() {
WiFi.begin(SSID, PWD);
while (WiFi.status() != WL_CONNECTED) {
delay(100);
}
}
void initSNTP() {
sntp_set_sync_interval(15 * 60 * 1000UL); // 15 minutes
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
setTimezone();
}
void setTimezone() {
setenv("TZ", TIMEZONE, 1);
tzset();
}
void drawTime() {
static char buff[50];
static struct tm t;
getLocalTime(&t);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(1);
sprintf(buff, " %2d:%02d:%02d ", t.tm_hour, t.tm_min, t.tm_sec);
tft.drawString(buff, tft.width() / 2, 130, 7);
sprintf(buff, " %s, %s %d, %d ",
DAYSTR[t.tm_wday], MONTHSTR[t.tm_mon],
t.tm_mday, t.tm_year + 1900);
tft.drawString(buff, tft.width() / 2, 200, 4);
}
bool isOn() {
static struct tm t;
getLocalTime(&t);
return schedule[t.tm_hour][t.tm_wday] && isAlarmOn;
}
void initDisplay() {
tft.begin();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
tft.setTouch(cal);
}
void btnView_pressed(void) {
if (btnView.justPressed()) {
isTimeView = !btnView.getState();
drawView();
tft.setTextFont(4);
tft.setTextSize(1);
btnView.drawSmoothButton(isTimeView, 1, TFT_BLACK, isTimeView ? "alarm" : "time");
}
}
void btnAlarm_pressed(void) {
if (btnAlarm.justPressed()) {
isAlarmOn = !btnAlarm.getState();
tft.setTextFont(4);
tft.setTextSize(1);
btnAlarm.drawSmoothButton(isAlarmOn, 1, TFT_DARKGREY, isAlarmOn ? "on" : "off");
}
}
void initButtons() {
uint16_t w = 100;
uint16_t h = 50;
uint16_t y = tft.height() - h + 14;
uint16_t x = tft.width() / 2;
tft.setTextFont(4);
tft.setTextSize(1);
btnView.initButtonUL(x - w - 10, y, w, h, TFT_DARKGREY, TFT_DARKGREY, TFT_BLACK, "alarm", 1);
btnView.setPressAction(btnView_pressed);
btnView.drawSmoothButton(isTimeView, 1, TFT_BLACK);
btnAlarm.initButtonUL(x + 10, y, w, h, TFT_DARKGREY, TFT_BLACK, TFT_DARKGREY, "off", 1);
btnAlarm.setPressAction(btnAlarm_pressed);
btnAlarm.drawSmoothButton(isAlarmOn, 1, TFT_BLACK);
}
void handleButtons() {
tft.setTextFont(4);
uint8_t nBtns = sizeof(btns) / sizeof(btns[0]);
uint16_t x = 0, y = 0;
bool touched = tft.getTouch(&x, &y);
for (uint8_t b = 0; b < nBtns; b++) {
if (touched) {
if (btns[b]->contains(x, y)) {
btns[b]->press(true);
btns[b]->pressAction();
}
} else {
btns[b]->press(false);
btns[b]->releaseAction();
}
}
}
void drawView() {
tft.fillScreen(TFT_BLACK);
initButtons();
if (isTimeView) {
drawTime();
} else {
drawSchedule();
drawLabels();
}
}
void updateView() {
static uint16_t x = 0, y = 0;
if (isTimeView) {
drawTime();
} else {
if (tft.getTouch(&x, &y)) {
selectSlot(x, y);
}
}
}
void setup() {
initWiFi();
initSNTP();
initDisplay();
pinMode(alarmPin, OUTPUT);
drawView();
}
void loop() {
updateView();
handleButtons();
digitalWrite(alarmPin, isOn() ? HIGH : LOW);
delay(100);
}
As you can see, this is quite a bit of code. The following section provides you with a quick overview of the functions and their meaning.
Functions Overview
- drawLabels(): Draws labels for the rows and columns on the display.
- drawSlot(int r, int c): Draws an hour slot on the schedule grid based on the provided row and column.
- drawSchedule(): Draws the entire schedule grid on the display.
- selectSlot(int x, int y): Allows users to select an hour slot on the schedule grid by touching the display.
- initWiFi(): Initializes the WiFi connection using the provided SSID and password.
- initSNTP(): Initializes the SNTP (Simple Network Time Protocol) for time synchronization.
- setTimezone(): Sets the timezone for the device based on the provided constant.
- drawTime(): Draws the current time and date on the display.
- isOn(): Checks if the current time corresponds to a scheduled slot and the alarm is enabled.
- initDisplay(): Initializes the display and sets up touch functionality.
- btnView_pressed() and btnAlarm_pressed(): Handle button press events for switching between time view and schedule view, and toggling the alarm status.
- initButtons(): Initializes the buttons on the display for interacting with the project.
- handleButtons(): Handles button press and release actions on the display.
- drawView(): Draws either the time view or the schedule view based on the current mode.
- updateView(): Updates the display based on user interactions and current mode.
Let's have a closer look at these functions and other parts of the code.
Library Inclusions
The code begins by including several libraries necessary for the functionality of the ESP32 and the display. We need libraries for the TFT display, the Wi-Fi connection, the time synchronization with an SNTP time server and the names of months and days (datestrsl.h
)
#include "tft_setup.h"
#include "TFT_eSPI.h"
#include "TFT_eWidget.h"
#include "stdarg.h"
#include "WiFi.h"
#include "esp_sntp.h"
#include "datestrs.h"
Constants and Variables
Next, we define several constants and variables that will be used throughout the program. These include Wi-Fi credentials, display dimensions, alarm pin, and a schedule array.
const char* TIMEZONE = "AEST-10AEDT,M10.1.0,M4.1.0/3";
const char* SSID = "SSID";
const char* PWD = "PASSWORD";
const int rs = 24, cs = 7;
const int alarmPin = 25;
bool schedule[rs][cs];
bool isTimeView = true;
bool isAlarmOn = false;
TIMEZONE
. The above time zone specification "AEST-10AEDT,M10.1.0,M4.1.0/3
" is for Australia, which corresponds to the Australian Eastern Standard Time (AEST) with daylight saving time adjustments.
The parts of this time zone definition are as follows
AEST: Australian Eastern Standard Time
-10: UTC offset of 10 hours ahead of Coordinated Universal Time (UTC)
AEDT: Australian Eastern Daylight Time
M10.1.0: Transition to daylight saving time occurs on the 1st Sunday of October
M4.1.0/3: Transition back to standard time occurs on the 1st Sunday of April, with a 3-hour difference from UTC.
For other time zone definitions have a look at the Posix Timezones Database. Just copy and paste the string you find there and change the TIMEZONE
constant accordingly.
Coordinate Conversion Functions
The following functions convert between coordinate systems for the display:
int c2x(int c) { ... }
int r2y(int r) { ... }
int x2c(int x) { ... }
int y2r(int y) { ... }
Drawing Functions
The code includes several functions to draw elements on the display:
drawLabels
This function draws the labels for the rows (hours) and columns (week days) of the schedule.
void drawLabels() {
tft.setTextFont(1);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
for (int r = 0; r < rs; r++) {
tft.setCursor(24, r2y(r));
tft.printf("%02d", r);
}
for (int c = 0; c < cs; c++) {
tft.setCursor(c2x(c), 15);
tft.print(DAYSTR[c]);
}
}
drawSlot
This function draws a single hour time slot in the schedule, filling it with white if the alarm is set for that time.
void drawSlot(int r, int c) {
uint32_t color = schedule[r][c] ? TFT_WHITE : TFT_BLACK;
tft.fillRect(c2x(c) + 1, r2y(r) + 1, cw - 2, ch - 2, color);
}
drawSchedule
This function draws the entire schedule grid, calling drawSlot()
for each time slot.
void drawSchedule() {
for (int r = 0; r < rs; r++) {
for (int c = 0; c < cs; c++) {
tft.drawRect(c2x(c), r2y(r), cw, ch, TFT_DARKGREY);
drawSlot(r, c);
}
}
}
selectSlot
This function toggles the state and color of a time slot when the user touches it on the display.
void selectSlot(int x, int y) {
int c = x2c(x);
int r = y2r(y);
if (c < 0 || c >= cs || r < 0 || r >= rs) return;
schedule[r][c] = !schedule[r][c];
drawSlot(r, c);
}
Wi-Fi and SNTP Initialization
The following functions handle the initialization of Wi-Fi and SNTP (Simple Network Time Protocol):
initWiFi
This function connects the ESP32 to the specified Wi-Fi network.
void initWiFi() {
WiFi.begin(SSID, PWD);
while (WiFi.status() != WL_CONNECTED) {
delay(100);
}
}
initSNTP
This function sets up the SNTP service to synchronize the time. It essentially connects to a server on the internet that provides very accurate time information and synchronizes the internal clock of the ESP32 accordingly.
void initSNTP() {
sntp_set_sync_interval(15 * 60 * 1000UL); // 15 minutes
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
setTimezone();
}
As long as there is a Wi-Fi and internet connection, this ensures that the clock of our digital timer switch is always accurate and automatically adjusts to daylight savings time. For more details have a look at the How to synchronize ESP32 clock with SNTP server tutorial.
setTimezone
This function sets the timezone for the application. You have to make sure that this functions runs every time the ESP32 starts, otherwise your clock will be off.
void setTimezone() {
setenv("TZ", TIMEZONE, 1);
tzset();
}
Time Display Function
The drawTime()
function retrieves the current local time, which is synchronized via SNTP, and displays the time and date on the screen.
void drawTime() {
static char buff[50];
static struct tm t;
getLocalTime(&t);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextDatum(MC_DATUM);
tft.setTextSize(1);
sprintf(buff, " %2d:%02d:%02d ", t.tm_hour, t.tm_min, t.tm_sec);
tft.drawString(buff, tft.width() / 2, 130, 7);
sprintf(buff, " %s, %s %d, %d ",
DAYSTR[t.tm_wday], MONTHSTR[t.tm_mon],
t.tm_mday, t.tm_year + 1900);
tft.drawString(buff, tft.width() / 2, 200, 4);
}
The function uses the constants for the names of days and months stored in the datestrs.h
file.
If you need more details have a look at the Digital Clock with CrowPanel 3.5″ ESP32 Display and the Digital Clock on e-Paper Display tutorials, which also implement digital clocks.
Alarm Check Function
The isOn()
function checks if the alarm should be triggered based on the current time and schedule. If the grid cell schedule[t.tm_hour][t.tm_wday]
contains true and the overall alarm switch is on (isAlarmOn
), the function returns true
and otherwise false
.
bool isOn() {
static struct tm t;
getLocalTime(&t);
return schedule[t.tm_hour][t.tm_wday] && isAlarmOn;
}
Display Initialization
The initDisplay()
function initializes the display by setting the screen rotation, filling the screen with black, setting the calibration parameter for the touch screen.
void initDisplay() {
tft.begin();
tft.setRotation(0);
tft.fillScreen(TFT_BLACK);
tft.setTouch(cal);
}
Button Functions
The code includes functions to handle button presses for toggling between time view and alarm settings:
btnView_pressed
This function toggles the view between the current time and the alarm schedule.
void btnView_pressed(void) {
if (btnView.justPressed()) {
isTimeView = !btnView.getState();
drawView();
tft.setTextFont(4);
tft.setTextSize(1);
btnView.drawSmoothButton(isTimeView, 1, TFT_BLACK, isTimeView ? "alarm" : "time");
}
}
btnAlarm_pressed
This function toggles the overall alarm switch on and off. If the switch is off the alarm schedule is disabled.
void btnAlarm_pressed(void) {
if (btnAlarm.justPressed()) {
isAlarmOn = !btnAlarm.getState();
tft.setTextFont(4);
tft.setTextSize(1);
btnAlarm.drawSmoothButton(isAlarmOn, 1, TFT_DARKGREY, isAlarmOn ? "on" : "off");
}
}
Button Initialization
The initButtons()
function initializes the buttons on the display.
void initButtons() {
uint16_t w = 100;
uint16_t h = 50;
uint16_t y = tft.height() - h + 14;
uint16_t x = tft.width() / 2;
tft.setTextFont(4);
tft.setTextSize(1);
btnView.initButtonUL(x - w - 10, y, w, h, TFT_DARKGREY, TFT_DARKGREY, TFT_BLACK, "alarm", 1);
btnView.setPressAction(btnView_pressed);
btnView.drawSmoothButton(isTimeView, 1, TFT_BLACK);
btnAlarm.initButtonUL(x + 10, y, w, h, TFT_DARKGREY, TFT_BLACK, TFT_DARKGREY, "off", 1);
btnAlarm.setPressAction(btnAlarm_pressed);
btnAlarm.drawSmoothButton(isAlarmOn, 1, TFT_BLACK);
}
Button Handling
The handleButtons()
function checks for button presses and executes the corresponding actions.
void handleButtons() {
tft.setTextFont(4);
uint8_t nBtns = sizeof(btns) / sizeof(btns[0]);
uint16_t x = 0, y = 0;
bool touched = tft.getTouch(&x, &y);
for (uint8_t b = 0; b < nBtns; b++) {
if (touched) {
if (btns[b]->contains(x, y)) {
btns[b]->press(true);
btns[b]->pressAction();
}
} else {
btns[b]->press(false);
btns[b]->releaseAction();
}
}
}
View Drawing Function
The drawView()
function updates the display based on the current view mode (time or schedule).
void drawView() {
tft.fillScreen(TFT_BLACK);
initButtons();
if (isTimeView) {
drawTime();
} else {
drawSchedule();
drawLabels();
}
}
View Update Function
The updateView()
function refreshes the display based on user interaction.
void updateView() {
static uint16_t x = 0, y = 0;
if (isTimeView) {
drawTime();
} else {
if (tft.getTouch(&x, &y)) {
selectSlot(x, y);
}
}
}
Setup Function
The setup()
function initializes the Wi-Fi, SNTP, display, and switches GPIO25 into output mode. We will connect an LED or an Relay to this pin and control them via the alarm schedule.
void setup() {
initWiFi();
initSNTP();
initDisplay();
pinMode(alarmPin, OUTPUT);
drawView();
}
Loop Function
Finally, the loop()
function continuously updates the view, handles button presses, and controls the alarm pin based on the schedule.
void loop() {
updateView();
handleButtons();
digitalWrite(alarmPin, isOn() ? HIGH : LOW);
delay(100);
}
And this is the complete code for a digital timer switch. In the next section, I show you how to connect a Relay to the Crowpanel Display, which we then can control via the alarm schedule.
Connecting a Relay to the Digital Timer Switch
If you want to control lights in your home or the pump of you sprinkler you will need a relay. The following diagram shows you how to wire that up:
VCC (red cable) and GND (black cable) are connected to the corresponding pins on a relay module. GPIO25 is connected to the input pin (yellow cable) of the relay.
The high voltage, high current device you want to control is connected to the COM and NC (normal open) terminals of the relay module. Be very careful when operating with voltages higher than 50V ! You must make sure that the relay module is rated for the voltage and current you want to switch. Typical relay modules (AITRIP, HiLetgo) can switch 220V at 4A up to 10A.
The photo below shows the wiring of the relay module with the CrowPanel Display
To test the digital timer switch read the current time and date, e.g. 12:37:00 on Wednesday and then set the corresponding time slot the alarm schedule:
If the overall alarm button is on, the Relay should be on. You then can switch it off by toggling the overall alarm button or changing the schedule.
Conclusion
In this tutorial you learned how to build a Digital Timer Switch with the CrowPanel 3.5" ESP32 Display. Compared to many commercially available products or Digital Timer Switch has always accurate time and due the large display makes it very easy to quickly set and change schedules.
There are many possible extensions to this project. You may want to be able to set schedules with a resolution of minutes or seconds. Or may be you want to program a schedule for an entire month or year.
Instead of connecting a relay you could also use Bluetooth, MQTT or other protocols to control devices wirelessly. The code above gives you a framework for it.
Happy tinkering ;)
Links
Here some links that I found helpful for writing this post.
CrowPanel 3.5"-ESP32 Display
CrowPanel ESP32 Display Wiki
CrowPanel ESP32 Display User_Manual
CrowPanel 3.5" Schematic Diagram
CrowPanel ESP32 Display Video Tutorials
TFT 3.5" Touch Screen & ESP32 built in - Elecrow review
Getting Started Tips 3.5" Elecrow TFT ESP32