ESP8266 스마트 기상 관측소

이 튜토리얼에서는 ESP8266으로 온라인 서비스에서 날씨를 읽어 1.28인치 원형 TFT LCD 디스플레이에 보여 주는 스마트 기상 관측소를 만드는 방법을 알려드립니다. 자세히 말하면, 다음 내용을 배웁니다:

추천 사항:

원형 tft lcd 디스플레이를 사용한 ESP8266 NodeMCU 스마트 기상 관측소

필요한 하드웨어

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

One button part is sufficient. Both the push button and the button module are wired in the section below.

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

스마트 기상 관측소에 대하여

이 기상 관측소에는 온도 센서가 없습니다. ESP8266이 WiFi 네트워크에 접속하여 날씨 서비스에 선택한 도시의 값을 요청하고, 그 응답을 원형 화면에 그립니다. 데이터가 웹 서비스에서 오기 때문에, 앞으로 몇 시간 동안의 강수 확률이나 다음 며칠간의 예보처럼 작은 센서로는 측정할 수 없는 값도 보여 줄 수 있습니다.

인터페이스는 여덟 개의 페이지로 나뉘어 있으며, 푸시 버튼 하나로 모든 페이지를 넘겨 볼 수 있습니다.

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

ESP8266의 메모리 한계

ESP8266은 이 프로젝트를 실행할 수 있는 보드 중 가장 제약이 큰 보드입니다. 스케치가 시작된 후 힙은 약 46 KB이며, HTTPS 연결 하나만으로도 BearSSL 버퍼에 약 20 KB를 사용합니다. 코드의 두 가지 결정이 모든 것을 그 예산 안에 유지해 줍니다:

결정 효과
예보 시간을 12시간만 요청 응답 크기가 약 5 KB에서 약 1.8 KB로 줄어듭니다
ArduinoJson 필터 적용 파싱 전에 "..._units" 블록이 버려집니다
파싱 후 페이로드 해제 그리기 전에 텍스트 버퍼가 힙으로 반환됩니다
TLS 클라이언트를 힙에 생성 요청이 끝나면 큰 버퍼가 사라집니다

※ 주의:

코드는 각 요청 전후에 남은 힙 크기를 출력합니다. 시리얼 모니터에서 그 숫자를 지켜보세요. 값이 약 12 KB 아래로 떨어지면 예보 시간을 줄이거나 일별 항목의 수를 줄이세요.

배선도

GC9A01 디스플레이는 ESP8266의 하드웨어 SPI 버스로 구동되며, D5가 클럭을, D7이 데이터를 전달합니다. 이 두 핀은 고정되어 있으므로 옮기면 안 됩니다.

TFT LCD 핀 ESP8266 설명
VCC 3.3V 전원 공급
GND GND 접지
SCL D5 SPI 클럭, 고정 핀
SDA D7 SPI MOSI, 고정 핀
DC D2 데이터/명령
CS D8 칩 선택
RST D1 리셋

버튼은 D6에서 읽습니다. 일반 푸시 버튼이나 버튼 모듈 중 하나를 연결할 수 있으며, 각각의 배선은 아래에 나와 있습니다.

ESP8266 - 푸시 버튼 배선

일반 푸시 버튼에는 저항이 없습니다. D6과 접지 사이에 연결해야 하며, 나머지는 ESP8266의 내부 풀업 저항이 처리하므로 외부 저항이 필요 없습니다. 핀은 평소에 HIGH이고, 버튼을 누르고 있는 동안 LOW가 됩니다.

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

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

ESP8266 핀배열 및 ESP8266 전원을 켜는 방법에 대해 더 많이 보십시오.

버튼 핀 ESP8266 설명
핀 1 D6 내부 풀업을 사용하는 버튼 입력
핀 2 GND 접지

제공된 스케치는 이 구성에 맞게 작성되어 있습니다.

ezButton button(PIN_BUTTON);

ESP8266 - 버튼 모듈 배선

버튼 모듈은 보드에 풀다운 저항이 있으므로 전원을 공급해야 합니다. OUT 핀은 버튼을 누르지 않았을 때 LOW를 유지하고 누르면 HIGH가 되며, 이는 일반 버튼과 반대입니다.

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

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

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

모듈의 저항이 내부 저항을 대신하므로, 생성자에서 모드를 지정해야 합니다.

ezButton button(PIN_BUTTON, EXTERNAL_PULLDOWN);

※ 주의:

ESP8266 보드에 인쇄된 핀 표시는 제조사마다 다릅니다. 위에서 사용한 D 번호는 NodeMCU 표시입니다. 배선하기 전에 항상 여러분의 보드에 인쇄된 표시와 비교하세요. 자세히 살펴보세요!

기상 관측소용 ESP8266 프로그래밍 방법

첫 번째 단계는 라이브러리를 포함하는 것입니다. 네트워크와 시간 헤더는 ESP8266 코어가 제공하고, ArduinoJson, DIYables_TFT_Round, ezButton은 라이브러리 매니저에서 설치합니다.

#include <ESP8266WiFi.h> #include <ESP8266HTTPClient.h> #include <WiFiClientSecureBearSSL.h> #include <ArduinoJson.h> #include <time.h> #include <DIYables_TFT_Round.h> #include <ezButton.h>

디스플레이에 연결된 ESP8266 핀을 지정한 다음, 디스플레이 객체를 선언합니다.

#define PIN_RST D1 #define PIN_DC D2 #define PIN_CS D8 DIYables_TFT_GC9A01_Round tft(PIN_RST, PIN_DC, PIN_CS);

네트워크 접속 정보와 관측할 장소를 지정합니다.

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

요청을 시도하기 전에 ESP8266이 네트워크에 접속되어 있어야 합니다.

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

시계는 NTP로 동기화됩니다. 모든 인증서에는 시작 날짜와 종료 날짜가 있으므로, 정확한 시계는 HTTPS에도 중요합니다.

configTime(GMT_OFFSET_SEC, DST_OFFSET_SEC, "pool.ntp.org", "time.nist.gov");

ESP8266 코어는 ESP32 코어와 달리 getLocalTime() 함수를 제공하지 않으므로, 시스템 시계를 직접 읽어야 합니다.

time_t now = time(nullptr); struct tm timeinfo; localtime_r(&now, &timeinfo);

보안 클라이언트는 힙에 생성됩니다. 요청이 끝나면 객체가 소멸되고 큰 TLS 버퍼가 힙으로 반환됩니다.

std::unique_ptr<BearSSL::WiFiClientSecure> client(new BearSSL::WiFiClientSecure); client->setInsecure(); HTTPClient http; http.begin(*client, url); int httpCode = http.GET(); String payload = http.getString();

파싱하기 전에 필터를 선언합니다. 실제로 그리는 세 블록만 유지하므로 문서가 작게 유지됩니다.

JsonDocument filter; filter["current"] = true; filter["hourly"] = true; filter["daily"] = true; JsonDocument doc; deserializeJson(doc, payload, DeserializationOption::Filter(filter));

그런 다음 값 하나를 추출하여 원형 디스플레이에 그립니다.

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

스마트 기상 관측소용 ESP8266 코드

/* * 이 ESP8266 NodeMCU 코드는 newbiely.kr 에서 개발되었습니다 * 이 ESP8266 NodeMCU 코드는 어떠한 제한 없이 공개 사용을 위해 제공됩니다. * 상세한 지침 및 연결도에 대해서는 다음을 방문하세요: * https://newbiely.kr/tutorials/esp8266/esp8266-smart-weather-station */ /* ============================================================ SMART WEATHER STATION ESP8266 NodeMCU + 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 -> ESP8266 VCC -> 3.3V GND -> GND SCL -> D5 (hardware SPI clock, fixed) SDA -> D7 (hardware SPI MOSI, fixed) RST -> D1 DC -> D2 CS -> D8 Button D6 -> BUTTON -> GND ------------------------------------------------------------ BUTTON Short press -> Next page Long press -> Refresh weather Very long press -> Back to home page ------------------------------------------------------------ The ESP8266 has a small heap, and the HTTPS connection needs a large part of it. Two steps keep the sketch inside the free memory: 1. Only 12 hours of forecast are requested. 2. A JSON filter drops the parts that are not drawn. ============================================================ */ #include <ESP8266WiFi.h> #include <ESP8266HTTPClient.h> #include <WiFiClientSecureBearSSL.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 ===== // D5 (SCLK) and D7 (MOSI) are the hardware SPI pins of the // ESP8266. The library drives them on its own, so they are // not listed here. #define PIN_BUTTON D6 #define PIN_RST D1 #define PIN_DC D2 #define PIN_CS D8 // ===== 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"; // Only 12 hours are asked for. The answer then starts at the // current hour and stays near 1.8 KB, which matters on the // small heap of the ESP8266. url += "&forecast_hours=12"; url += "&temperature_unit=celsius"; url += "&wind_speed_unit=kmh"; return url; } // ===== LOCAL TIME HELPER ===== // The ESP8266 core has no getLocalTime() like the ESP32, so // the system clock is read directly here. bool readLocalTime(struct tm* info) { time_t now = time(nullptr); // Before the NTP answer arrives, the clock sits near 1970. if (now < 100000) { return false; } localtime_r(&now, info); return true; } // ===== PARSE WEATHER ===== bool parseWeather(const String& payload) { // A filter keeps only the three blocks that are drawn. // The "..._units" blocks are dropped, which saves a good // part of the ESP8266 heap. JsonDocument filter; filter["current"] = true; filter["hourly"] = true; filter["daily"] = true; JsonDocument doc; DeserializationError error = deserializeJson(doc, payload, DeserializationOption::Filter(filter)); 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 // "forecast_hours=12" makes the list begin at the current // hour, so index 0 is now and index 11 is 11 hours later. JsonArray hourlyTime = doc["hourly"]["time"]; JsonArray hourlyTemp = doc["hourly"]["temperature_2m"]; JsonArray hourlyRain = doc["hourly"]["precipitation_probability"]; JsonArray hourlyCode = doc["hourly"]["weather_code"]; for (int i = 0; i < 12; i++) { // Safety check if (i >= (int)hourlyTemp.size()) { weather.hourlyTemp[i] = weather.temperature; weather.hourlyRainChance[i] = 0; weather.hourlyWeatherCode[i] = weather.weatherCode; weather.hourlyTime[i] = "--:--"; continue; } weather.hourlyTemp[i] = hourlyTemp[i] | weather.temperature; weather.hourlyRainChance[i] = hourlyRain[i] | 0; weather.hourlyWeatherCode[i] = hourlyCode[i] | weather.weatherCode; // Extract HH:MM // Example: // 2026-09-22T14:00 // // Result: // 14:00 String fullTime = hourlyTime[i] | ""; 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("================================"); Serial.print("FREE HEAP BEFORE: "); Serial.println(ESP.getFreeHeap()); isFetching = true; // Loading screen is shown only once. drawLoadingScreen("FETCHING WEATHER..."); // BearSSL does the HTTPS part on the ESP8266. The object is // built on the heap and freed right after the request, so the // large TLS buffer does not stay in memory. std::unique_ptr<BearSSL::WiFiClientSecure> client(new BearSSL::WiFiClientSecure); // Prototype / testing mode: the server certificate is not // checked. It keeps the sketch short and saves memory. 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(); http.end(); Serial.print("PAYLOAD SIZE: "); Serial.println(payload.length()); bool result = parseWeather(payload); // The text is not needed any more, so the heap is given back // before the screen is drawn. payload = ""; 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); Serial.print("FREE HEAP AFTER: "); Serial.println(ESP.getFreeHeap()); 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 < WIFI_CONNECT_TIMEOUT) { 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 ===== // A correct clock also helps the HTTPS handshake, because a // certificate carries a start date and an end date. void setupTime() { configTime( GMT_OFFSET_SEC, DST_OFFSET_SEC, "pool.ntp.org", "time.nist.gov", "time.google.com" ); Serial.println("SYNCING TIME..."); struct tm timeinfo; unsigned long start = millis(); while (!readLocalTime(&timeinfo) && millis() - start < 10000) { delay(250); Serial.print("."); } Serial.println(); if (readLocalTime(&timeinfo)) { Serial.println("TIME SYNCED"); char buffer[32]; strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &timeinfo); Serial.println(buffer); } 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 (!readLocalTime(&timeinfo)) { 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("================================"); Serial.print("FREE HEAP AT BOOT: "); Serial.println(ESP.getFreeHeap()); // 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(); }

빠른 단계

다음 안내를 단계별로 따르세요:

  • ESP8266을 처음 사용한다면, ESP8266 - 소프트웨어 설치 튜토리얼을 참고하세요.
  • 배선도와 같이 부품을 배선하세요.
  • USB 케이블로 ESP8266 보드를 컴퓨터에 연결하세요.
  • 컴퓨터에서 Arduino IDE를 여세요.
  • 올바른 ESP8266 보드(예: NodeMCU 1.0 (ESP-12E Module))와 COM 포트를 선택하세요.
  • Arduino IDE 왼쪽 바의 Libraries 아이콘으로 이동하세요.
  • “DIYables TFT Round”를 검색한 다음, DIYables의 DIYables_TFT_Round 라이브러리를 찾으세요.
  • Install 버튼을 클릭하여 라이브러리를 설치하세요.
  • 추가 라이브러리 의존성을 설치해야 합니다.
  • Install All 버튼을 클릭하여 필요한 모든 라이브러리를 설치하세요.
  • “ArduinoJson”을 검색한 다음, Benoit Blanchon의 ArduinoJson 라이브러리를 찾으세요.
  • Install 버튼을 클릭하여 라이브러리를 추가하세요.
  • “ezButton”을 검색한 다음, ArduinoGetStarted의 ezButton 라이브러리를 찾으세요.
  • Install 버튼을 클릭하여 라이브러리를 추가하세요.
  • 코드를 복사하여 Arduino IDE에서 여세요.
  • WIFI_SSID와 WIFI_PASSWORD를 여러분의 네트워크 이름과 비밀번호로 바꾸세요.
  • LATITUDE, LONGITUDE, LOCATION_NAME, TIMEZONE, GMT_OFFSET_SEC를 여러분 도시의 값으로 바꾸세요.
  • Arduino IDE의 Upload 버튼을 클릭하여 코드를 ESP8266에 전송하세요.
  • Arduino IDE에서 시리얼 모니터를 열고 보드레이트를 115200으로 설정하세요.
  • 부팅 화면, WiFi 화면, 그리고 날씨 화면이 나타날 때까지 기다리세요.
  • 버튼을 눌러 페이지를 넘겨 보세요.
  • 시리얼 모니터에서 결과를 확인하세요.
∞
Newbiely | Arduino IDE 2.3.8
──
☐
✕
File
Edit
Sketch
Tools
Help
Nodemcu 1.0 (ESP-12E Module)
Newbiely.ino
···
8 Serial.println("Hello World!");
Output
Serial Monitor
Message (Enter to send message to 'Nodemcu 1.0 (ESP-12E Module)' on 'COM15')
New Line
9600 baud
================================ SMART WEATHER STATION SEOUL ================================ FREE HEAP AT BOOT: 46512 CONNECTING TO WIFI... ....... WIFI CONNECTED IP: 192.168.1.58 RSSI: -66 dBm SYNCING TIME... .... TIME SYNCED 2026-09-22 18:05:31 ================================ FETCHING WEATHER ================================ FREE HEAP BEFORE: 44120 https://api.open-meteo.com/v1/forecast?latitude=37.5665&longitude=126.9780¤t=... HTTP CODE: 200 PAYLOAD SIZE: 1754 WEATHER UPDATED Temperature: 19.40 Humidity: 72.00 Weather: SHOWERS Wind: 16.50 Rain Chance: 54 FREE HEAP AFTER: 41864 SHORT PRESS -> PAGE 2 LONG PRESS -> REFRESH VERY LONG PRESS -> HOME
Ln 11, Col 1
Nodemcu 1.0 (ESP-12E Module) on COM15
2

코드 설명

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

버튼을 읽는 방법

버튼 하나가 세 가지 다른 동작을 수행합니다. 누르고 있는 시간에 따라 어떤 동작이 실행될지 결정됩니다.

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

버튼은 ezButton 라이브러리로 관리됩니다. 접점의 기계적 바운스는 라이브러리가 제거하므로, 한 번 누른 것이 두 번 인식되지 않습니다. 생성자가 핀을 INPUT_PULLUP 모드로 설정하므로 pinMode() 호출이 필요 없습니다.

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 }

ESP8266 - 버튼 튜토리얼에서 디바운스 기법을 따로 설명합니다.

위치를 변경하는 방법

스케치는 대한민국 서울로 설정되어 제공됩니다. 코드 상단의 다섯 가지 정의가 장소를 결정합니다:

#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으로 설정해야 합니다.

추가 지식

이 프로젝트에 사용할 보드를 고르기 전에 ESP8266과 ESP32를 비교해 볼 만합니다.

ESP8266 ESP32
스케치에 사용 가능한 힙 약 46 KB 약 280 KB
요청하는 예보 시간 12시간, 메모리 절약용 72시간, 3일 전체
JSON 필터 필수 선택
로컬 시간 함수 직접 작성 getLocalTime() 제공
CPU 코어 1 2
일반적인 가격 더 저렴 더 비쌈

메모리 부담이 완전히 사라지므로, 이 프로젝트에는 ESP32가 훨씬 편하다는 것이 분명합니다. 그럼에도 위에서 설명한 두 가지 메모리 절약 방법을 유지한다면, ESP8266도 충분히 쓸 만하고 더 저렴한 선택입니다.

문제 해결

  • 디스플레이가 검은색으로 유지됩니다. SCL과 SDA 선은 D5와 D7에 연결되어야 합니다. 이 두 핀은 하드웨어 SPI 버스에 속하므로 옮길 수 없습니다.
  • 요청 중에 보드가 재시작됩니다. 힙이 부족해진 것입니다. getWeatherURL()에서 예보 시간을 줄이거나, 일별 항목을 세 개 대신 두 개만 요청하세요.
  • HTTP CODE가 음수입니다. 보안 핸드셰이크가 실패했습니다. 시계가 잘못되면 모든 인증서가 유효하지 않은 것처럼 보이므로, 시간이 동기화되었는지 확인하세요.
  • WiFi가 전혀 연결되지 않습니다. ESP8266은 2.4 GHz 네트워크만 지원합니다. 5 GHz로만 동작하는 네트워크에는 절대 접속할 수 없습니다.
  • 시계가 --:--로 표시됩니다. NTP 응답이 오지 않았습니다. 일부 네트워크에서는 UDP 포트 123이 차단되어 있습니다.

동영상

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

댓글