ESP32 스마트 기상 관측소

이 튜토리얼은 ESP32와 1.28인치 원형 TFT LCD 디스플레이를 사용하여, 센서가 아닌 인터넷에서 실시간 날씨를 읽어 오는 스마트 기상 관측소를 만드는 방법을 안내합니다.

배울 내용은 다음과 같습니다:

원형 tft lcd 디스플레이가 있는 ESP32 스마트 기상 관측소

ESP32에는 WiFi가 내장되어 있으므로 별도의 네트워크 모듈이 필요하지 않습니다. 날씨는 웹 서비스에서 가져오기 때문에, 저렴한 센서로는 측정할 수 없는 값, 예를 들어 앞으로 몇 시간 동안의 강수 확률과 향후 3일간의 예보도 표시할 수 있습니다.

필요한 하드웨어

1×ESP32 Development Board 쿠팡 | 아마존
1×마이크로 USB 케이블 아마존
1×1.28 Inch Round Circular TFT LCD Display Module 쿠팡 | 아마존
1×Push Button 쿠팡 | 아마존
1×Button Module (또는) 아마존
1×브레드보드 쿠팡 | 아마존
1×점퍼케이블 쿠팡 | 아마존

Only one of the two button parts is required. Both options are wired below.

1×(추천) ESP32용 스크루 터미널 확장 보드 쿠팡 | 아마존
1×(추천) Breakout Expansion Board for ESP32 쿠팡 | 아마존
1×(추천) ESP32용 전원 분배기 쿠팡 | 아마존
공개: 이 포스팅 에 제공된 일부 링크는 아마존 제휴 링크입니다. 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

기상 관측소 페이지 소개

기상 관측소에는 8개의 페이지가 있습니다. 푸시 버튼 하나로 페이지를 차례로 넘깁니다:

페이지 표시 내용
HOME 날씨 아이콘, 온도, 하늘 상태 설명, NTP 시계
TEMP 온도와 체감 온도
HUMIDITY 링으로 그린 습도와 정확한 백분율
WIND 풍속과 나침반에 표시된 풍향
RAIN 애니메이션 비 효과와 함께 표시되는 강수 확률
NEXT HOURS 시간별 온도와 강수 확률
FORECAST 향후 3일간의 최고 및 최저 온도
SYSTEM WiFi 상태, 신호 세기, API 상태, 위치

ESP32는 10분마다 날씨를 새로 고칩니다. 두 번의 새로 고침 사이에도 홈 페이지의 시계는 계속 움직입니다. ESP32가 NTP 동기화 후 자체적으로 시간을 유지하기 때문입니다.

Open-Meteo 날씨 API 소개

Open-Meteo는 날씨 데이터를 제공합니다. ESP32 프로젝트에 잘 맞습니다:

Open-Meteo
API 키 필요 없음, 가입 불필요
비용 개인 용도로 무료
응답 형식 일반 JSON
위치 위도와 경도로 지정
프로토콜 443 포트의 HTTPS

한 번의 요청으로 현재 날씨, 향후 며칠간의 시간별 값, 3일간의 일별 요약을 받을 수 있습니다. timezone 파라미터를 사용하면 서비스가 UTC 대신 현지 시간을 반환합니다.

배선도

디스플레이는 ESP32의 기본 하드웨어 SPI 버스로 통신하므로 GPIO18과 GPIO23은 고정되어 있습니다.

TFT LCD 핀 ESP32 설명
VCC 3.3V 전원 공급
GND GND 접지
SCL GPIO18 SPI 클럭 (고정)
SDA GPIO23 SPI MOSI (고정)
DC GPIO25 데이터/명령
CS GPIO26 칩 선택
RST GPIO27 리셋

GPIO16의 버튼 입력에는 일반 푸시 버튼이나 완성된 버튼 모듈 중 어느 것이든 연결할 수 있습니다. 가지고 있는 부품을 연결한 다음, 그에 맞게 코드를 설정하세요.

※ 주의:

GPIO16은 여기서 사용하는 ESP32-WROOM-32 보드에서 일반 입력 핀입니다. 스트래핑 핀이 아니며, 내부 풀업이 정상적으로 동작합니다.

알아 두어야 할 예외가 하나 있습니다: PSRAM이 탑재된 ESP32-WROVER 모듈에서는 GPIO16과 GPIO17이 PSRAM 칩에 연결되어 있어 사용할 수 없습니다. 보드에 PSRAM이 있다면 버튼을 다른 빈 핀(예: GPIO22)으로 옮기고, 코드의 PIN_BUTTON도 그에 맞게 변경하세요.

옵션 1: 일반 푸시 버튼

푸시 버튼에는 자체 저항이 없습니다. 한쪽 다리는 GPIO16에, 다른 쪽 다리는 접지에 연결하고, ESP32가 내부적으로 풀업을 제공합니다. 따라서 핀은 평소에 HIGH 상태이고, 버튼을 누르고 있는 동안 LOW로 떨어집니다.

푸시 버튼을 사용한 ESP32 스마트 기상 관측소 배선도

이 이미지는 Fritzing을 사용하여 만들어졌습니다. 이미지를 확대하려면 클릭하세요.

ESP32 및 다른 구성 요소에 전원을 공급하는 방법에 대해 잘 알지 못하는 경우, 다음 튜토리얼에서 안내를 찾을 수 있습니다: ESP32 전원 공급 방법.

버튼 핀 ESP32 설명
핀 1 GPIO16 버튼 입력, 내부 풀업
핀 2 GND 접지

스케치는 기본적으로 이 옵션에 맞게 설정되어 있습니다:

ezButton button(PIN_BUTTON);

옵션 2: 버튼 모듈

버튼 모듈은 보드 자체에 풀다운 저항이 있으며 전원 공급이 필요합니다. OUT 핀은 평소에 LOW이고 누르면 HIGH로 올라가며, 이는 일반 버튼과 반대입니다.

버튼 모듈을 사용한 ESP32 스마트 기상 관측소 배선도

이 이미지는 Fritzing을 사용하여 만들어졌습니다. 이미지를 확대하려면 클릭하세요.

버튼 모듈 핀 ESP32 설명
VCC 3.3V 전원 공급
GND GND 접지
OUT GPIO16 신호, 평소 LOW, 누르면 HIGH

모듈에 자체 저항이 있으므로 내부 저항은 꺼 두어야 합니다. 한 줄만 바꾸면 됩니다:

ezButton button(PIN_BUTTON, EXTERNAL_PULLDOWN);

※ 주의:

디스플레이는 3.3V로 전원을 공급하세요. 많은 보드에서 GC9A01 모듈이 5V를 견디지만, 3.3V가 ESP32의 로직 레벨과 일치하므로 더 안전한 선택입니다.

기상 관측소를 위한 ESP32 프로그래밍 방법

라이브러리를 포함합니다. WiFi.h, WiFiClientSecure.h, HTTPClient.h, time.h는 모두 ESP32 코어에 포함되어 있으므로, ArduinoJson, DIYables_TFT_Round, ezButton만 라이브러리 매니저에서 설치하면 됩니다.

#include <WiFi.h> #include <WiFiClientSecure.h> #include <HTTPClient.h> #include <ArduinoJson.h> #include <time.h> #include <DIYables_TFT_Round.h> #include <ezButton.h>

리셋, 데이터/명령, 칩 선택 핀으로 디스플레이 객체를 만듭니다.

DIYables_TFT_GC9A01_Round tft(27, 25, 26);

WiFi 정보와 관측하려는 위치를 입력합니다.

#define WIFI_SSID "YOUR_WIFI_SSID" #define WIFI_PASSWORD "YOUR_WIFI_PASSWORD" #define LATITUDE 37.5665 #define LONGITUDE 126.9780

네트워크에 연결합니다.

WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

NTP로 시계를 동기화합니다. GMT_OFFSET_SEC에는 UTC와의 시차를 초 단위로 넣습니다.

configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, "pool.ntp.org", "time.nist.gov"); struct tm timeinfo; getLocalTime(&timeinfo, 10000);

보안 연결을 열고 요청을 보냅니다. setInsecure()는 인증서 검사를 건너뛰므로, 프로토타입용 스케치를 짧게 유지할 수 있습니다.

WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.begin(client, url); int httpCode = http.GET(); String payload = http.getString();

응답을 ArduinoJson에 넘기고 값을 꺼냅니다.

JsonDocument doc; deserializeJson(doc, payload); float temperature = doc["current"]["temperature_2m"] | 0.0;

원형 화면에 표시합니다.

tft.setTextSize(3); tft.setTextColor(DIYables_TFT::colorRGB(255, 255, 255)); tft.setCursor(80, 100); tft.print(temperature, 1);

ESP32 코드 - 스마트 기상 관측소

/* * 이 ESP32 코드는 newbiely.kr 에서 개발되었습니다 * 이 ESP32 코드는 어떠한 제한 없이 공개 사용을 위해 제공됩니다. * 상세한 지침 및 연결도에 대해서는 다음을 방문하세요: * https://newbiely.kr/tutorials/esp32/esp32-smart-weather-station */ /* ============================================================ SMART WEATHER STATION ESP32 + GC9A01 Round TFT + Push Button Location: Seoul, South Korea (change it in USER CONFIGURATION) Weather API: Open-Meteo (free, no API key needed) ------------------------------------------------------------ GC9A01 Round TFT -> ESP32 VCC -> 3.3V GND -> GND SCL -> GPIO18 (hardware SPI clock, fixed) SDA -> GPIO23 (hardware SPI MOSI, fixed) RST -> GPIO27 DC -> GPIO25 CS -> GPIO26 Button GPIO16 -> BUTTON -> GND ------------------------------------------------------------ BUTTON Short press -> Next page Long press -> Refresh weather Very long press -> Back to home page ============================================================ */ #include <WiFi.h> #include <WiFiClientSecure.h> #include <HTTPClient.h> #include <ArduinoJson.h> #include <time.h> #include <DIYables_TFT_Round.h> #include <ezButton.h> // ===== USER CONFIGURATION ===== #define WIFI_SSID "YOUR_WIFI_SSID" #define WIFI_PASSWORD "YOUR_WIFI_PASSWORD" #define LATITUDE 37.5665 #define LONGITUDE 126.9780 #define LOCATION_NAME "SEOUL" // Time zone name for the API, URL-encoded. // "/" must be written as "%2F". #define TIMEZONE "Asia%2FSeoul" // Offset from UTC in seconds, used by the NTP clock. // Seoul is UTC+9, so 9 * 3600. #define GMT_OFFSET_SEC (9 * 3600) // Extra offset for summer time. Use 3600 where it applies. #define DST_OFFSET_SEC 0 // ===== PIN CONFIGURATION ===== // SCK (GPIO18) and MOSI (GPIO23) are the hardware SPI pins of // the ESP32. The library uses them on its own, so they are not // listed here. #define PIN_BUTTON 16 #define PIN_RST 27 #define PIN_DC 25 #define PIN_CS 26 // ===== TIMING ===== const unsigned long API_UPDATE_INTERVAL = 10UL * 60UL * 1000UL; const unsigned long WIFI_RETRY_INTERVAL = 10UL * 1000UL; const unsigned long WIFI_CONNECT_TIMEOUT = 15UL * 1000UL; const unsigned long BUTTON_DEBOUNCE = 40; const unsigned long LONG_PRESS_TIME = 600; const unsigned long VERY_LONG_PRESS_TIME = 2000; const unsigned long RAIN_ANIMATION_INTERVAL = 90; const unsigned long CLOCK_UPDATE_INTERVAL = 1000; // ===== TFT ===== DIYables_TFT_GC9A01_Round tft(PIN_RST, PIN_DC, PIN_CS); // ===== SCREEN ===== const int SCREEN_W = 240; const int SCREEN_H = 240; const int CENTER_X = 120; const int CENTER_Y = 120; // ===== COLORS ===== #define BLACK DIYables_TFT::colorRGB(0, 0, 0) #define WHITE DIYables_TFT::colorRGB(255, 255, 255) #define CYAN DIYables_TFT::colorRGB(0, 220, 255) #define BLUE DIYables_TFT::colorRGB(40, 120, 255) #define LIGHT_BLUE DIYables_TFT::colorRGB(90, 190, 255) #define GREEN DIYables_TFT::colorRGB(60, 230, 130) #define YELLOW DIYables_TFT::colorRGB(255, 210, 60) #define ORANGE DIYables_TFT::colorRGB(255, 150, 40) #define RED DIYables_TFT::colorRGB(255, 70, 70) #define PURPLE DIYables_TFT::colorRGB(180, 100, 255) #define GRAY DIYables_TFT::colorRGB(125, 125, 135) #define DARK_GRAY DIYables_TFT::colorRGB(42, 42, 52) #define DARK_BLUE DIYables_TFT::colorRGB(12, 18, 48) // ===== PAGE ===== enum Page { PAGE_HOME = 0, PAGE_TEMP, PAGE_HUMIDITY, PAGE_WIND, PAGE_RAIN, PAGE_HOURLY, PAGE_FORECAST, PAGE_SYSTEM, PAGE_COUNT }; Page currentPage = PAGE_HOME; // ===== WEATHER DATA ===== struct WeatherData { bool valid = false; float temperature = 0; float humidity = 0; float feelsLike = 0; float windSpeed = 0; float windDirection = 0; float pressure = 0; int weatherCode = 0; bool isDay = true; int currentRainChance = 0; float uvIndex = 0; String sunrise; String sunset; // NEXT HOURS float hourlyTemp[12]; int hourlyRainChance[12]; int hourlyWeatherCode[12]; String hourlyTime[12]; // DAILY String dailyDate[3]; float dailyTempMax[3]; float dailyTempMin[3]; int dailyRainChance[3]; int dailyWeatherCode[3]; float dailyUV[3]; }; WeatherData weather; // ===== SYSTEM STATE ===== bool apiOnline = false; bool wifiOnline = false; bool screenDirty = true; bool isFetching = false; bool pendingRefresh = false; unsigned long lastApiUpdate = 0; unsigned long lastWifiRetry = 0; unsigned long wifiConnectStart = 0; unsigned long lastRainAnimation = 0; unsigned long lastClockUpdate = 0; bool wifiConnecting = false; // ===== BUTTON ===== // ezButton handles the debouncing, so the sketch only has to // measure how long the button was held down. // // The line below is for a BARE push button wired to GND. ezButton then // uses the internal pull-up resistor of the board, and the pin reads LOW // while the button is held down. ezButton button(PIN_BUTTON); // If you use a BUTTON MODULE instead, the module carries its own resistor // on the board, so the internal one must not be used. Comment the line // above and use this line instead: // ezButton button(PIN_BUTTON, EXTERNAL_PULLDOWN); unsigned long buttonPressStart = 0; // ===== RAIN ANIMATION ===== const int RAIN_DROP_COUNT = 16; int rainX[RAIN_DROP_COUNT]; int rainY[RAIN_DROP_COUNT]; bool rainInitialized = false; const int RAIN_AREA_X = 30; const int RAIN_AREA_Y = 125; const int RAIN_AREA_W = 180; const int RAIN_AREA_H = 78; // ===== WEATHER DESCRIPTION ===== String weatherDescription(int code) { switch (code) { case 0: return "CLEAR"; case 1: return "MAINLY CLEAR"; case 2: return "PARTLY CLOUDY"; case 3: return "OVERCAST"; case 45: case 48: return "FOG"; case 51: case 53: case 55: return "DRIZZLE"; case 56: case 57: return "FREEZING DRIZZLE"; case 61: case 63: case 65: return "RAIN"; case 66: case 67: return "FREEZING RAIN"; case 71: case 73: case 75: return "SNOW"; case 77: return "SNOW GRAINS"; case 80: case 81: case 82: return "SHOWERS"; case 85: case 86: return "SNOW SHOWERS"; case 95: return "THUNDERSTORM"; case 96: case 99: return "STORM"; default: return "UNKNOWN"; } } // ===== WEATHER COLOR ===== uint16_t weatherColor(int code) { if (code == 0) return YELLOW; if (code == 1 || code == 2) return CYAN; if (code == 3) return GRAY; if (code >= 51 && code <= 67) return BLUE; if (code >= 80 && code <= 82) return LIGHT_BLUE; if (code >= 95) return RED; return WHITE; } // ===== WIND DIRECTION ===== String windDirectionText(float degrees) { const char* directions[] = { "N", "NE", "E", "SE", "S", "SW", "W", "NW" }; int index = (int)((degrees + 22.5) / 45.0); index %= 8; return directions[index]; } // ===== CENTER TEXT ===== void drawCenteredText(String text, int y, int size, uint16_t color) { tft.setTextSize(size); tft.setTextColor(color); int width = text.length() * 6 * size; int x = CENTER_X - width / 2; if (x < 0) x = 0; tft.setCursor(x, y); tft.print(text); } // ===== HEADER ===== void drawHeader(String title, uint16_t color) { // Safe radius from screen center (slightly less than the // physical 120px radius, to keep a small margin from the // round bezel). const float SAFE_RADIUS = 116.0; int size = 2; int width = title.length() * 6 * size; int half = width / 2; int y; if (half >= SAFE_RADIUS) { // Too wide even at the lowest safe position -> use a // smaller font instead of clipping. size = 1; width = title.length() * 6 * size; half = width / 2; y = 14; } else { // Compute the minimum y (top of text) so the top corners // of the text stay inside the round visible area. float insideSpan = sqrt((SAFE_RADIUS * SAFE_RADIUS) - ((float)half * (float)half)); int minY = (int)(120.0 - insideSpan); y = (minY > 14) ? minY : 14; } drawCenteredText(title, y, size, color); int lineY = y + (size == 2 ? 16 : 8) + 6; tft.drawLine(45, lineY, 195, lineY, DARK_GRAY); } // ===== FOOTER ===== void drawFooter() { tft.drawLine(55, 214, 185, 214, DARK_GRAY); drawCenteredText(String(currentPage + 1) + "/" + String(PAGE_COUNT), 220, 1, GRAY); } // ===== SUN ICON ===== void drawSun(int x, int y, int radius) { tft.fillCircle(x, y, radius, YELLOW); for (int i = 0; i < 8; i++) { float angle = i * PI / 4.0; int x1 = x + cos(angle) * (radius + 7); int y1 = y + sin(angle) * (radius + 7); int x2 = x + cos(angle) * (radius + 14); int y2 = y + sin(angle) * (radius + 14); tft.drawLine(x1, y1, x2, y2, YELLOW); } } // ===== CLOUD ICON ===== void drawCloud(int x, int y) { tft.fillCircle(x - 20, y, 15, LIGHT_BLUE); tft.fillCircle(x, y - 8, 20, LIGHT_BLUE); tft.fillCircle(x + 20, y, 15, LIGHT_BLUE); tft.fillRoundRect(x - 35, y, 70, 20, 10, LIGHT_BLUE); } // ===== STATIC RAIN ICON ===== void drawRainIcon(int x, int y) { drawCloud(x, y); for (int i = 0; i < 5; i++) { int dx = -24 + i * 12; int dy = 28; tft.drawLine(x + dx, y + dy, x + dx - 4, y + dy + 10, BLUE); } } // ===== WEATHER ICON ===== void drawWeatherIcon(int code, int x, int y) { if (code == 0) { drawSun(x, y, 18); } else if (code <= 2) { drawSun(x - 12, y - 7, 13); drawCloud(x + 10, y + 10); } else if (code == 3) { drawCloud(x, y); } else if ((code >= 51 && code <= 67) || (code >= 80 && code <= 82)) { drawRainIcon(x, y); } else if (code >= 95) { drawCloud(x, y); tft.drawLine(x - 5, y + 28, x - 12, y + 43, YELLOW); tft.drawLine(x + 10, y + 28, x + 3, y + 43, YELLOW); } else { drawCloud(x, y); } } void drawWifiIcon(int cx, int cy, int level) { const int radii[3] = { 14, 27, 40 }; for (int arc = 0; arc < 3; arc++) { uint16_t color = (arc < level) ? CYAN : DARK_GRAY; for (int deg = -150; deg <= -30; deg += 5) { float angle = deg * PI / 180.0; int x = cx + (int)(cos(angle) * radii[arc]); int y = cy + (int)(sin(angle) * radii[arc]); tft.fillCircle(x, y, 2, color); } } // Base dot (the "device") tft.fillCircle(cx, cy, 5, CYAN); } // ===== PAGE 1 - HOME ===== void drawHome() { uint16_t bg = weather.isDay ? BLACK : DARK_BLUE; tft.fillScreen(bg); if (!weather.valid) { drawCenteredText("NO DATA", 90, 3, RED); drawCenteredText("CHECK CONNECTION", 135, 1, WHITE); drawFooter(); return; } // Weather icon drawWeatherIcon(weather.weatherCode, CENTER_X, 65); // Temperature drawCenteredText(String(weather.temperature, 1) + " C", 112, 3, WHITE); // Location drawCenteredText(LOCATION_NAME, 153, 2, CYAN); // Weather condition drawCenteredText(weatherDescription(weather.weatherCode), 178, 1, weatherColor(weather.weatherCode)); // Clock area drawHomeClock(); drawFooter(); } // ===== HOME CLOCK ===== void drawHomeClock() { uint16_t bg = weather.isDay ? BLACK : DARK_BLUE; // Only clear a small region. // This prevents full-screen flicker. tft.fillRect(82, 196, 76, 12, bg); drawCenteredText(getTimeString(), 197, 1, GRAY); } // ===== PAGE 2 - TEMPERATURE ===== void drawTemperature() { tft.fillScreen(BLACK); drawHeader("TEMPERATURE", ORANGE); int cx = 120; int cy = 125; int radius = 68; int value = constrain((int)weather.temperature, 0, 40); int filled = map(value, 0, 40, 0, 240); // Temperature gauge for (int i = 0; i < 240; i += 4) { float angle = (-135.0 + i) * PI / 180.0; int x = cx + cos(angle) * radius; int y = cy + sin(angle) * radius; uint16_t color = (i < filled) ? ORANGE : DARK_GRAY; tft.fillCircle(x, y, 2, color); } // Main value drawCenteredText(String(weather.temperature, 1), 101, 3, WHITE); drawCenteredText("C", 138, 2, ORANGE); drawCenteredText("FEELS " + String(weather.feelsLike, 1) + " C", 168, 1, CYAN); drawFooter(); } // ===== PAGE 3 - HUMIDITY ===== void drawHumidity() { tft.fillScreen(BLACK); drawHeader("HUMIDITY", BLUE); int cx = 120; int cy = 125; int radius = 68; int progress = constrain((int)weather.humidity, 0, 100); int filled = map(progress, 0, 100, 0, 270); for (int i = 0; i < 270; i += 3) { float angle = (-135.0 + i) * PI / 180.0; int x = cx + cos(angle) * radius; int y = cy + sin(angle) * radius; uint16_t color = (i < filled) ? BLUE : DARK_GRAY; tft.fillCircle(x, y, 3, color); } drawCenteredText(String((int)weather.humidity) + "%", 105, 3, WHITE); String comfort; if (weather.humidity < 40) comfort = "DRY"; else if (weather.humidity < 70) comfort = "COMFORTABLE"; else if (weather.humidity < 85) comfort = "HUMID"; else comfort = "VERY HUMID"; drawCenteredText(comfort, 153, 1, CYAN); drawFooter(); } // ===== PAGE 4 - WIND ===== void drawWind() { tft.fillScreen(BLACK); drawHeader("WIND", GREEN); int cx = 120; int cy = 104; int radius = 49; // Compass tft.drawCircle(cx, cy, radius, DARK_GRAY); drawCenteredText("N", 43, 1, WHITE); drawCenteredText("S", 157, 1, WHITE); tft.setCursor(62, 101); tft.setTextSize(1); tft.setTextColor(WHITE); tft.print("W"); tft.setCursor(172, 101); tft.print("E"); // Direction arrow float angle = weather.windDirection * PI / 180.0; int x2 = cx + sin(angle) * 38; int y2 = cy - cos(angle) * 38; tft.drawLine(cx, cy, x2, y2, GREEN); tft.fillCircle(cx, cy, 5, GREEN); // Wind speed drawCenteredText(String(weather.windSpeed, 1) + " km/h", 174, 2, WHITE); drawCenteredText(windDirectionText(weather.windDirection), 198, 1, CYAN); drawFooter(); } // ===== PAGE 5 - RAIN ===== void drawRain() { tft.fillScreen(BLACK); drawHeader("RAIN", LIGHT_BLUE); int rain = weather.currentRainChance; // Static weather icon if (rain >= 60) { drawRainIcon(CENTER_X, 68); } else { drawCloud(CENTER_X, 73); } // Rain percentage drawCenteredText(String(rain) + "%", 105, 3, WHITE); drawCenteredText("RAIN CHANCE", 140, 1, LIGHT_BLUE); // Status if (rain >= 60) { drawCenteredText("HIGH", 158, 1, RED); } else if (rain >= 30) { drawCenteredText("MODERATE", 158, 1, YELLOW); } else { drawCenteredText("LOW", 158, 1, GREEN); } // Animation area if (rain >= 20) { initializeRainAnimation(); } drawFooter(); } // ===== INITIALIZE RAIN ANIMATION ===== void initializeRainAnimation() { for (int i = 0; i < RAIN_DROP_COUNT; i++) { rainX[i] = RAIN_AREA_X + random(RAIN_AREA_W); rainY[i] = RAIN_AREA_Y + random(RAIN_AREA_H); } rainInitialized = true; } // ===== UPDATE RAIN ANIMATION ===== void updateRainAnimation() { if (currentPage != PAGE_RAIN) return; if (weather.currentRainChance < 20) return; if (millis() - lastRainAnimation < RAIN_ANIMATION_INTERVAL) { return; } lastRainAnimation = millis(); uint16_t bg = BLACK; // Erase old drops for (int i = 0; i < RAIN_DROP_COUNT; i++) { tft.drawLine(rainX[i], rainY[i], rainX[i] - 3, rainY[i] + 7, bg); } // Move drops for (int i = 0; i < RAIN_DROP_COUNT; i++) { rainY[i] += 5; if (rainY[i] > RAIN_AREA_Y + RAIN_AREA_H) { rainX[i] = RAIN_AREA_X + random(RAIN_AREA_W); rainY[i] = RAIN_AREA_Y; } tft.drawLine(rainX[i], rainY[i], rainX[i] - 3, rainY[i] + 7, BLUE); } } // ===== PAGE 6 - NEXT HOURS ===== void drawHourly() { tft.fillScreen(BLACK); drawHeader("NEXT HOURS", PURPLE); for (int i = 0; i < 5; i++) { int y = 52 + i * 31; // Row separator if (i > 0) { tft.drawLine(25, y - 8, 215, y - 8, DARK_GRAY); } // Time tft.setCursor(25, y); tft.setTextSize(1); tft.setTextColor(WHITE); tft.print(weather.hourlyTime[i]); // Weather int code = weather.hourlyWeatherCode[i]; String condition; if (code == 0) condition = "SUN"; else if (code <= 3) condition = "CLOUD"; else if (code >= 95) condition = "STORM"; else condition = "RAIN"; tft.setCursor(75, y); tft.setTextColor(weatherColor(code)); tft.print(condition); // Temperature tft.setCursor(142, y); tft.setTextColor(WHITE); tft.print(weather.hourlyTemp[i], 0); tft.print("C"); // Rain tft.setCursor(180, y); tft.setTextColor(LIGHT_BLUE); tft.print(weather.hourlyRainChance[i]); tft.print("%"); } drawFooter(); } // ===== PAGE 7 - FORECAST ===== // UI FIX: // The card border (drawRoundRect) previously spanned x=15..225 // (width 210). For the TOP row, the corners of that rectangle // fell outside the visible round area of the screen and were // clipped. The card width below was reduced to 190px // (x=25..215) which keeps every corner, on every row, safely // inside the round bezel. Text start x was nudged in slightly // to match the new card padding. void drawForecast() { tft.fillScreen(BLACK); drawHeader("3-DAY FORECAST", CYAN); const char* labels[] = { "TODAY", "TOMORROW", "DAY 3" }; // Card geometry (UI fix: narrower so it never touches // the round bezel, even on the top row). const int CARD_X = 25; const int CARD_W = 190; for (int i = 0; i < 3; i++) { int y = 62 + i * 48; // Card tft.drawRoundRect(CARD_X, y - 5, CARD_W, 40, 8, DARK_GRAY); // Day tft.setCursor(CARD_X + 8, y + 4); tft.setTextSize(1); tft.setTextColor(WHITE); tft.print(labels[i]); // Temperature tft.setCursor(CARD_X + 65, y + 4); tft.print(weather.dailyTempMin[i], 0); tft.print("/"); tft.print(weather.dailyTempMax[i], 0); tft.print("C"); // Rain tft.setCursor(CARD_X + 120, y + 4); tft.setTextColor(LIGHT_BLUE); tft.print(weather.dailyRainChance[i]); tft.print("%"); // Weather tft.setCursor(CARD_X + 155, y + 4); tft.setTextColor(weatherColor(weather.dailyWeatherCode[i])); int code = weather.dailyWeatherCode[i]; if (code == 0) tft.print("SUN"); else if (code <= 3) tft.print("CLD"); else if (code >= 95) tft.print("STM"); else tft.print("RAIN"); } drawFooter(); } // ===== PAGE 8 - SYSTEM ===== void drawSystem() { tft.fillScreen(BLACK); drawHeader("SYSTEM", CYAN); // WIFI tft.setCursor(25, 57); tft.setTextSize(1); tft.setTextColor(WHITE); tft.print("WIFI"); tft.setCursor(125, 57); if (WiFi.status() == WL_CONNECTED) { tft.setTextColor(GREEN); tft.print("ONLINE"); } else { tft.setTextColor(RED); tft.print("OFFLINE"); } // RSSI tft.setCursor(25, 82); tft.setTextColor(WHITE); tft.print("RSSI"); tft.setCursor(125, 82); if (WiFi.status() == WL_CONNECTED) { tft.setTextColor(CYAN); tft.print(WiFi.RSSI()); tft.print(" dBm"); } else { tft.print("--"); } // API tft.setCursor(25, 107); tft.setTextColor(WHITE); tft.print("API"); tft.setCursor(125, 107); if (apiOnline) { tft.setTextColor(GREEN); tft.print("ONLINE"); } else { tft.setTextColor(RED); tft.print("OFFLINE"); } // LOCATION tft.setCursor(25, 132); tft.setTextColor(WHITE); tft.print("LOCATION"); tft.setCursor(125, 132); tft.setTextColor(CYAN); tft.print(LOCATION_NAME); // TEMP tft.setCursor(25, 157); tft.setTextColor(WHITE); tft.print("TEMP"); tft.setCursor(125, 157); tft.print(weather.temperature, 1); tft.print(" C"); // STATUS tft.setCursor(25, 182); tft.setTextColor(WHITE); tft.print("STATUS"); tft.setCursor(125, 182); if (weather.valid) { tft.setTextColor(GREEN); tft.print("READY"); } else { tft.setTextColor(RED); tft.print("NO DATA"); } drawFooter(); } // ===== RENDER PAGE ===== void renderPage() { // Rain animation must be initialized // only when entering the page. rainInitialized = false; switch (currentPage) { case PAGE_HOME: drawHome(); break; case PAGE_TEMP: drawTemperature(); break; case PAGE_HUMIDITY: drawHumidity(); break; case PAGE_WIND: drawWind(); break; case PAGE_RAIN: drawRain(); break; case PAGE_HOURLY: drawHourly(); break; case PAGE_FORECAST: drawForecast(); break; case PAGE_SYSTEM: drawSystem(); break; } } // ===== WEATHER API URL ===== String getWeatherURL() { String url = "https://api.open-meteo.com/v1/forecast"; url += "?latitude=" + String(LATITUDE, 4); url += "&longitude=" + String(LONGITUDE, 4); url += "&current=" "temperature_2m," "relative_humidity_2m," "apparent_temperature," "weather_code," "wind_speed_10m," "wind_direction_10m," "pressure_msl," "is_day"; url += "&hourly=" "temperature_2m," "precipitation_probability," "weather_code"; url += "&daily=" "temperature_2m_max," "temperature_2m_min," "precipitation_probability_max," "weather_code," "sunrise," "sunset," "uv_index_max"; url += "&timezone=" TIMEZONE; url += "&forecast_days=3"; url += "&temperature_unit=celsius"; url += "&wind_speed_unit=kmh"; return url; } // ===== PARSE WEATHER ===== bool parseWeather(String payload) { JsonDocument doc; DeserializationError error = deserializeJson(doc, payload); if (error) { Serial.print("JSON ERROR: "); Serial.println(error.c_str()); return false; } // CURRENT JsonObject current = doc["current"]; if (current.isNull()) { Serial.println("CURRENT DATA MISSING"); return false; } weather.temperature = current["temperature_2m"] | 0.0; weather.humidity = current["relative_humidity_2m"] | 0.0; weather.feelsLike = current["apparent_temperature"] | 0.0; weather.weatherCode = current["weather_code"] | 0; weather.windSpeed = current["wind_speed_10m"] | 0.0; weather.windDirection = current["wind_direction_10m"] | 0.0; weather.pressure = current["pressure_msl"] | 0.0; int isDay = current["is_day"] | 1; weather.isDay = (isDay == 1); // HOURLY JsonArray hourlyTime = doc["hourly"]["time"]; JsonArray hourlyTemp = doc["hourly"]["temperature_2m"]; JsonArray hourlyRain = doc["hourly"]["precipitation_probability"]; JsonArray hourlyCode = doc["hourly"]["weather_code"]; struct tm timeinfo; int currentHour = 0; if (getLocalTime(&timeinfo, 1000)) { currentHour = timeinfo.tm_hour; } for (int i = 0; i < 12; i++) { int index = currentHour + i; // Safety check if (index >= hourlyTemp.size()) { weather.hourlyTemp[i] = weather.temperature; weather.hourlyRainChance[i] = 0; weather.hourlyWeatherCode[i] = weather.weatherCode; weather.hourlyTime[i] = "--:--"; continue; } weather.hourlyTemp[i] = hourlyTemp[index] | weather.temperature; weather.hourlyRainChance[i] = hourlyRain[index] | 0; weather.hourlyWeatherCode[i] = hourlyCode[index] | weather.weatherCode; // Extract HH:MM // Example: // 2026-09-11T14:00 // // Result: // 14:00 String fullTime = hourlyTime[index] | ""; if (fullTime.length() >= 16) { weather.hourlyTime[i] = fullTime.substring(11, 16); } else { weather.hourlyTime[i] = "--:--"; } } weather.currentRainChance = weather.hourlyRainChance[0]; // DAILY JsonArray dailyMax = doc["daily"]["temperature_2m_max"]; JsonArray dailyMin = doc["daily"]["temperature_2m_min"]; JsonArray dailyRain = doc["daily"]["precipitation_probability_max"]; JsonArray dailyCode = doc["daily"]["weather_code"]; JsonArray dailyDate = doc["daily"]["time"]; JsonArray dailySunrise = doc["daily"]["sunrise"]; JsonArray dailySunset = doc["daily"]["sunset"]; JsonArray dailyUV = doc["daily"]["uv_index_max"]; for (int i = 0; i < 3; i++) { weather.dailyTempMax[i] = dailyMax[i] | 0.0; weather.dailyTempMin[i] = dailyMin[i] | 0.0; weather.dailyRainChance[i] = dailyRain[i] | 0; weather.dailyWeatherCode[i] = dailyCode[i] | 0; weather.dailyDate[i] = dailyDate[i] | ""; weather.dailyUV[i] = dailyUV[i] | 0.0; } if (dailySunrise.size() > 0) { weather.sunrise = dailySunrise[0] | ""; } if (dailySunset.size() > 0) { weather.sunset = dailySunset[0] | ""; } if (dailyUV.size() > 0) { weather.uvIndex = dailyUV[0] | 0.0; } weather.valid = true; return true; } // ===== FETCH WEATHER ===== bool fetchWeather() { if (WiFi.status() != WL_CONNECTED) { Serial.println("WIFI NOT CONNECTED"); apiOnline = false; screenDirty = true; return false; } Serial.println(); Serial.println("================================"); Serial.println("FETCHING WEATHER"); Serial.println("================================"); isFetching = true; // Loading screen is shown only once. drawLoadingScreen("FETCHING WEATHER..."); WiFiClientSecure client; // Prototype / testing mode. client.setInsecure(); HTTPClient http; String url = getWeatherURL(); Serial.println(url); if (!http.begin(client, url)) { Serial.println("HTTP BEGIN FAILED"); isFetching = false; apiOnline = false; screenDirty = true; return false; } http.setTimeout(15000); int httpCode = http.GET(); Serial.print("HTTP CODE: "); Serial.println(httpCode); if (httpCode <= 0) { Serial.print("HTTP ERROR: "); Serial.println(http.errorToString(httpCode)); http.end(); isFetching = false; apiOnline = false; screenDirty = true; return false; } if (httpCode != HTTP_CODE_OK) { Serial.println("API ERROR"); http.end(); isFetching = false; apiOnline = false; screenDirty = true; return false; } String payload = http.getString(); Serial.print("PAYLOAD SIZE: "); Serial.println(payload.length()); bool result = parseWeather(payload); http.end(); isFetching = false; if (result) { apiOnline = true; lastApiUpdate = millis(); Serial.println("WEATHER UPDATED"); Serial.print("Temperature: "); Serial.println(weather.temperature); Serial.print("Humidity: "); Serial.println(weather.humidity); Serial.print("Weather: "); Serial.println(weatherDescription(weather.weatherCode)); Serial.print("Wind: "); Serial.println(weather.windSpeed); Serial.print("Rain Chance: "); Serial.println(weather.currentRainChance); screenDirty = true; return true; } apiOnline = false; screenDirty = true; return false; } // ===== LOADING SCREEN ===== void drawLoadingScreen(String message) { tft.fillScreen(BLACK); drawCenteredText("SMART WEATHER", 48, 2, CYAN); drawCenteredText("STATION", 73, 2, WHITE); // Loading ring for (int i = 0; i < 12; i++) { float angle = i * 2.0 * PI / 12.0; int x = CENTER_X + cos(angle) * 42; int y = CENTER_Y + sin(angle) * 42; uint16_t color = (i == 0) ? CYAN : DARK_GRAY; tft.fillCircle(x, y, 4, color); } drawCenteredText(message, 182, 1, WHITE); } // ===== WIFI CONNECT ===== bool connectWiFi() { Serial.println(); Serial.println("CONNECTING TO WIFI..."); tft.fillScreen(BLACK); drawCenteredText("CONNECTING", 26, 2, CYAN); drawWifiIcon(CENTER_X, 95, 0); drawCenteredText("WI-FI", 145, 2, WHITE); drawCenteredText(WIFI_SSID, 178, 1, GRAY); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); unsigned long start = millis(); int iconFrame = 0; while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) { drawWifiIcon(CENTER_X, 95, iconFrame % 4); iconFrame++; delay(250); Serial.print("."); } Serial.println(); if (WiFi.status() == WL_CONNECTED) { wifiOnline = true; wifiConnecting = false; drawWifiIcon(CENTER_X, 95, 3); Serial.println("WIFI CONNECTED"); Serial.print("IP: "); Serial.println(WiFi.localIP()); Serial.print("RSSI: "); Serial.print(WiFi.RSSI()); Serial.println(" dBm"); return true; } wifiOnline = false; Serial.println("WIFI CONNECTION FAILED"); return false; } // ===== START WIFI RECONNECT ===== void startWiFiReconnect() { Serial.println("START WIFI RECONNECT"); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); wifiConnecting = true; wifiConnectStart = millis(); } // ===== HANDLE WIFI ===== void handleWiFi() { wl_status_t status = WiFi.status(); // Connected if (status == WL_CONNECTED) { if (!wifiOnline) { Serial.println("WIFI CONNECTED"); screenDirty = true; } wifiOnline = true; wifiConnecting = false; return; } wifiOnline = false; // Currently connecting if (wifiConnecting) { if (millis() - wifiConnectStart > WIFI_CONNECT_TIMEOUT) { Serial.println("WIFI CONNECT TIMEOUT"); WiFi.disconnect(true); wifiConnecting = false; lastWifiRetry = millis(); } return; } // Start another attempt if (millis() - lastWifiRetry >= WIFI_RETRY_INTERVAL) { lastWifiRetry = millis(); startWiFiReconnect(); } } // ===== NTP TIME ===== void setupTime() { // The offset comes from USER CONFIGURATION. configTime( GMT_OFFSET_SEC, DST_OFFSET_SEC, "pool.ntp.org", "time.nist.gov", "time.google.com" ); Serial.println("SYNCING TIME..."); struct tm timeinfo; if (getLocalTime(&timeinfo, 10000)) { Serial.println("TIME SYNCED"); Serial.println(&timeinfo, "%Y-%m-%d %H:%M:%S"); } else { Serial.println("TIME SYNC FAILED"); } } // ===== BUTTON HANDLER ===== void handleButton() { button.loop(); // MUST call the loop() function first // The moment the button goes down, remember when it happened. if (button.isPressed()) buttonPressStart = millis(); // The action is chosen when the button comes back up. if (button.isReleased()) { unsigned long duration = millis() - buttonPressStart; if (duration >= VERY_LONG_PRESS_TIME) { // VERY LONG PRESS -> back to the home page currentPage = PAGE_HOME; screenDirty = true; Serial.println("VERY LONG PRESS -> HOME"); } else if (duration >= LONG_PRESS_TIME) { // LONG PRESS -> ask the weather API for fresh data pendingRefresh = true; Serial.println("LONG PRESS -> REFRESH"); } else { // SHORT PRESS -> next page currentPage = (Page)((currentPage + 1) % PAGE_COUNT); screenDirty = true; Serial.print("SHORT PRESS -> PAGE "); Serial.println(currentPage + 1); } } } // ===== PROCESS MANUAL REFRESH ===== void handleManualRefresh() { if (!pendingRefresh) return; pendingRefresh = false; fetchWeather(); } // ===== AUTO API UPDATE ===== void handleApiUpdate() { if (!weather.valid) { return; } if (millis() - lastApiUpdate >= API_UPDATE_INTERVAL) { fetchWeather(); } } // ===== CLOCK ===== String getTimeString() { struct tm timeinfo; if (!getLocalTime(&timeinfo, 10)) { return "--:--"; } char buffer[10]; strftime(buffer, sizeof(buffer), "%H:%M", &timeinfo); return String(buffer); } // ===== HANDLE CLOCK ===== void handleClock() { if (currentPage != PAGE_HOME) { return; } if (millis() - lastClockUpdate < CLOCK_UPDATE_INTERVAL) { return; } lastClockUpdate = millis(); if (weather.valid && !isFetching) { drawHomeClock(); } } // ===== SETUP ===== void setup() { Serial.begin(115200); delay(500); Serial.println(); Serial.println("================================"); Serial.println("SMART WEATHER STATION"); Serial.println(LOCATION_NAME); Serial.println("================================"); // BUTTON // ezButton already set the pin to INPUT_PULLUP in its constructor. button.setDebounceTime(BUTTON_DEBOUNCE); // TFT tft.begin(); tft.setRotation(2); tft.fillScreen(BLACK); // BOOT SCREEN drawCenteredText("SMART WEATHER", 65, 2, CYAN); drawCenteredText("STATION", 92, 2, WHITE); drawCenteredText(LOCATION_NAME, 135, 2, YELLOW); delay(1500); // WIFI if (connectWiFi()) { setupTime(); delay(500); fetchWeather(); } else { tft.fillScreen(BLACK); drawCenteredText("WIFI ERROR", 82, 2, RED); drawCenteredText("CHECK SETTINGS", 120, 1, WHITE); drawCenteredText("RETRYING...", 150, 1, GRAY); delay(1500); } screenDirty = true; } // ===== LOOP ===== void loop() { // Input handleButton(); // WiFi handleWiFi(); // Manual refresh handleManualRefresh(); // Automatic API update handleApiUpdate(); // Clock handleClock(); // Static page rendering if (screenDirty && !isFetching) { renderPage(); screenDirty = false; } // Rain animation updateRainAnimation(); }

빠른 단계

  • ESP32를 처음 사용하는 경우, ESP32 - 소프트웨어 설치을 참조하세요.
  • 제공된 배선도에 따라 원형 TFT LCD 디스플레이와 푸시 버튼을 ESP32에 연결합니다.
  • USB 케이블로 ESP32 보드를 PC에 연결합니다.
  • PC에서 Arduino IDE를 엽니다.
  • 올바른 ESP32 보드(예: ESP32 Dev Module)와 COM 포트를 선택합니다.
  • Arduino IDE 왼쪽 바에 있는 Libraries 아이콘으로 이동합니다.
  • “DIYables TFT Round”를 검색한 다음, DIYables의 DIYables_TFT_Round 라이브러리를 찾습니다.
  • Install 버튼을 클릭하여 라이브러리를 설치합니다.
ESP32 tft lcd 라이브러리 설치
  • 의존성 라이브러리를 설치하라는 창이 나타납니다.
  • Install All 버튼을 클릭하여 모든 의존성 라이브러리를 설치합니다.
ESP32 tft lcd 의존성 설치
  • 검색창에 “ArduinoJson”을 입력하고, Benoit Blanchon의 ArduinoJson 라이브러리를 찾은 다음 Install을 클릭합니다.
  • 검색창에 “ezButton”을 입력하고, ArduinoGetStarted의 ezButton 라이브러리를 찾은 다음 Install을 클릭합니다.
  • 위 코드를 복사하여 Arduino IDE 편집기에 붙여넣습니다.
  • WIFI_SSID와 WIFI_PASSWORD를 자신의 WiFi 이름과 비밀번호로 바꿉니다.
  • LATITUDE, LONGITUDE, LOCATION_NAME, TIMEZONE, GMT_OFFSET_SEC를 자신이 사는 도시의 값으로 바꿉니다.
  • Arduino IDE에서 Upload 버튼을 클릭하여 코드를 ESP32에 업로드합니다.
  • 시리얼 모니터를 열고 보드레이트를 115200으로 설정합니다.
  • 부팅 화면, WiFi 화면, 그리고 첫 번째 날씨 페이지가 차례로 나타나는지 확인합니다.
  • 버튼을 눌러 다음 페이지로 이동합니다.
  • 시리얼 모니터에서 결과를 확인합니다.
∞
Newbiely | Arduino IDE 2.3.8
──
☐
✕
File
Edit
Sketch
Tools
Help
ESP32 Dev Module
Newbiely.ino
···
8 Serial.println("Hello World!");
Output
Serial Monitor
Message (Enter to send message to 'ESP32 Dev Module' on 'COM15')
New Line
9600 baud
================================ SMART WEATHER STATION SEOUL ================================ CONNECTING TO WIFI... ...... WIFI CONNECTED IP: 192.168.1.113 RSSI: -52 dBm SYNCING TIME... TIME SYNCED 2026-09-22 09:15:42 ================================ FETCHING WEATHER ================================ https://api.open-meteo.com/v1/forecast?latitude=37.5665&longitude=126.9780¤t=... HTTP CODE: 200 PAYLOAD SIZE: 5188 WEATHER UPDATED Temperature: 21.80 Humidity: 68.00 Weather: OVERCAST Wind: 13.70 Rain Chance: 39 SHORT PRESS -> PAGE 2 SHORT PRESS -> PAGE 3 LONG PRESS -> REFRESH VERY LONG PRESS -> HOME
Ln 11, Col 1
ESP32 Dev Module on COM15
2

버튼 하나로 세 가지 동작

스케치는 버튼을 누르고 있는 시간을 측정하여 그에 따라 동작을 선택합니다:

누르는 시간 동작
0.6초 미만 다음 페이지로 이동, 마지막 페이지 다음에는 HOME으로 돌아감
0.6초 ~ 2초 날씨 API에 즉시 새 데이터를 요청
2초 초과 바로 HOME 페이지로 돌아감

버튼은 ezButton 라이브러리가 처리하며, 이 라이브러리가 접점 바운스를 처리하므로 한 번 누른 것이 두 번으로 인식되지 않습니다. 객체를 선언할 때 핀을 지정하면, 라이브러리가 자동으로 핀을 INPUT_PULLUP으로 설정합니다.

ezButton button(PIN_BUTTON);

디바운스 시간은 setup()에서 한 번 설정합니다.

button.setDebounceTime(40);

루프 안에서는 먼저 button.loop()를 실행한 다음, 누름 및 뗌 이벤트를 읽습니다. 두 이벤트 사이의 간격이 동작을 결정하는 누르는 시간입니다.

button.loop(); // MUST call the loop() function first if (button.isPressed()) buttonPressStart = millis(); if (button.isReleased()) { unsigned long duration = millis() - buttonPressStart; // duration decides the action }

ESP32 - 버튼 튜토리얼에서 디바운스 개념을 더 자세히 설명합니다.

자신의 도시 설정하기

스케치에는 기본 위치로 서울이 설정되어 있습니다. 상단 근처의 다섯 줄이 이를 제어합니다:

#define LATITUDE 37.5665 #define LONGITUDE 126.9780 #define LOCATION_NAME "SEOUL" #define TIMEZONE "Asia%2FSeoul" #define GMT_OFFSET_SEC (9 * 3600)
  • LATITUDE와 LONGITUDE는 자신의 도시를 가리킵니다. 아무 지도 웹사이트에서나 확인할 수 있습니다.
  • LOCATION_NAME은 화면에 표시되는 이름입니다. 짧은 이름이 원형 디스플레이에 더 잘 맞습니다.
  • TIMEZONE은 웹 주소에 들어가므로 / 문자를 %2F로 써야 합니다. Europe/Berlin은 Europe%2FBerlin이 됩니다.
  • GMT_OFFSET_SEC는 NTP 시계에 사용됩니다. 겨울철 베를린은 UTC+1이므로 (1 * 3600)입니다.
  • DST_OFFSET_SEC는 서머타임이 적용되는 지역에서 이를 더합니다. 그런 경우 3600을 사용하고, 그렇지 않으면 0으로 둡니다.

※ 주의:

측정값은 섭씨와 km/h 단위로 표시됩니다. 야드파운드 단위를 원한다면 getWeatherURL() 안의 temperature_unit과 wind_speed_unit 파라미터를 fahrenheit와 mph로 변경하세요.

코드 설명

소스 코드 주석에 있는 각 줄의 설명을 확인하세요!

문제 해결

  • 디스플레이에 아무것도 나타나지 않습니다. SCL이 GPIO18에, SDA가 GPIO23에 연결되어 있는지 다시 확인하세요. 이 두 핀은 하드웨어 SPI 버스에 속하므로 옮길 수 없습니다.
  • 스케치가 WiFi 화면에서 멈춰 있습니다. ESP32는 2.4 GHz 네트워크에만 연결됩니다. 5 GHz 전용 네트워크에는 절대 연결되지 않습니다.
  • HTTP CODE가 음수입니다. 보안 연결이 실패했습니다. ESP32가 인터넷에 접속할 수 있는지 확인하세요. 또한 캡티브 포털 방식의 WiFi는 API 호출을 차단한다는 점에 유의하세요.
  • 시계가 --:--로 표시됩니다. NTP 동기화가 완료되지 않았습니다. 일부 네트워크는 NTP에 필요한 UDP 123 포트를 차단합니다.
  • 스케치가 플래시 메모리에 들어가지 않습니다. Arduino IDE에서 Tools를 열고, Huge APP처럼 애플리케이션 영역이 더 큰 파티션 스킴을 선택하세요.

동영상

비디오 제작은 시간이 많이 걸리는 작업입니다. 비디오 튜토리얼이 학습에 도움이 되었다면, YouTube 채널 을 구독하여 알려 주시기 바랍니다. 비디오에 대한 높은 수요가 있다면, 비디오를 만들기 위해 노력하겠습니다.

댓글