Maker.io main logo

DIY 5-Day Rainfall Forecast Device - ESP32 E-Paper Project

2025-12-08 | By Mirko Pavleski

License: General Public License Air Pressure ESP32

In several of my previous projects, I have presented ways to make weather stations, but this time I decided to make it a specialized device with a strictly defined purpose. Namely, at least in my experience, the basic weather information for planning any activity in the coming days is whether there is a chance of rainfall in that period. Unfortunately, in commercial and even most DIY weather stations, this information is either not available at all or is quite brief in the form of a current-day rainfall forecast. That's why this time I decided to create a specialized weather station with the purpose of providing detailed information about the possibility of precipitation in the current and next five days.

 

For this purpose, data from OpenWeatherMap is used, where you can create a free API key. The display shows the predicted amount of precipitation in mm, the time of day in which it is predicted, as well as the percentage probability of rain on a specific day. Specifically, in this project, I used the CrowPanel ESP32 4.2” E-paper Display module with built-in ESP32S3 MCU. It is very practical in the sense that there is no need to connect components and solder, and it has multiple IO ports, a microSD slot, multiple buttons, and even a battery charger circuit.

At first glance, you can see that I didn't pay special attention to the visual part of the display, in the sense that there are no advanced graphic shapes and images, but only rectangles that represent the days, so the required information can be determined very fast and easily.

e5ry

Now let's explain how it works and describe the information displayed on the screen.

- In the upper left part of the screen, there is the basic information about the current weather. In the first line, we have descriptive information about the weather condition, then the temperature in degrees Celsius with one decimal place, then the air pressure value, and the air humidity expressed in percent. Next, we have the label UT (Update time), which tells us at what time the data was last downloaded.

hfj

In the upper right part, there is a space where the current situation regarding rainfall is indicated, as well as the forecast until the end of the current day. The space is divided by horizontal and vertical dashed lines. The vertical ones show the time (24 hours): midnight, 6AM, noon, 6PM, and midnight. The horizontal lines serve for a more precise orientation of the predicted rainfall amounts in mm. The numbers on the left edge show the value of this parameter. It is very important to emphasize that for better visibility, this value is variable and depends on the maximum amount of precipitation in a 3-hour period, which is predicted for that day.

tiy

So, as I mentioned, the amount of precipitation is predicted at a distance of 3 hours and is expressed through vertical bars. At the top is the day of the week as well as the POP symbol, which in translation means "probability of precipitation" and is indicated in the unit percentage.

- The lower half of the display contains the forecasts for the next 5 days of the week. Analogous to the previous description, the day is indicated at the top, and the POP value in percentage at the bottom. The amount of precipitation is expressed on the far left, but now the maximum value is dynamically adjusted depending on the maximum predicted amount of precipitation throughout the next five days.

dsff

Now, in this case, each day is divided by four horizontal and two vertical lines. The horizontal lines serve for a more precise visual assessment of the amount of precipitation, and the vertical line indicates the time: midnight, noon, and midnight. As before, precipitation is represented graphically with vertical bars. With the free API key from OpenWeatherMap, we get precipitation data with a resolution of 3 hours. If no precipitation is predicted for a particular day, then the message "NO RAIN" appears in the middle of the rectangle indicating the particular day.

Now, a few words about the code and some settings in it: Before uploading it, we need to enter some information into the code. First, we need to enter the credentials of the local Wi-Fi network. I specifically use the device in two places with different Wi-Fi networks, so in order not to constantly change the credentials, I made this part of the code in a way that the data from the two available networks is entered, and the device connects to the currently available Wi-Fi network. In fact, this is a very practical option, and you can enter any number of wireless networks.

fdjh

Then we enter the previously generated API key for the specific location from the OpenWeatherMap site, as well as the coordinates (latitude and longitude). Also, in the "void syncTime" function in the "config time" line, we need to enter the difference between UTC and local time in seconds. (For my location, local time is UTC + 2h, so I put 7200 sec).

hfjug

I created separate functions for each part of the screen so the code is easy to understand and customizable, so we can change multiple parameters such as the width of the bars, imperial or metric units, font, and others.

During testing, I noticed that the readability of the display in a poorly lit room improves if the colors on the screen are inverted. For this reason, I added an option for inverted colors (negative) by pressing a button on the device during boot. When refreshing (updating the screen), the color scheme does not change.

dfbg

The device is currently set to check for new data on OpenWeatherMap every 15 minutes (this can be easily changed at the end of the code), and then goes into the so-called DEEP SLEEP mode. In this mode, the consumption is almost 0. In this way, the battery lasts an exceptionally long time, which is one of the positive features of e-paper displays.

If, for some reason, the data from OpenWeatherMaps is not downloaded, all data is 0.0, and "-1" appears on the Current Weather screen in UT, which is a sign of an unsuccessful download. The data will be downloaded in the next 15 minutes, or immediately, if we press the reset button or the off/on switch.

bfdh

Now, let's briefly see how the device works in real conditions. When turned on, we can follow the entire boot process on the serial monitor and determine if there is any error. If it passes without errors, the data is printed on the screen, and the weather station enters DEEP SLEEP mode for the next 15 minutes. Below are several screenshots from different time periods and at different stages of the device's development.

ghkg

This is an elegant e-paper weather display that shows current conditions and 5-day Rainfall (precipitation) forecasts using ESP32 and OpenWeatherMap API. Perfect for makers who want a low-power, always-on specialized weather station with excellent functionality and crisp daylight visibility!

Copy Code
// ESP32 + E-paper weather forecast display with inversion feature by mircemk, June 2025

#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClient.h>
#include <ArduinoJson.h>
#include <GxEPD2_BW.h>
#include <Fonts/FreeMonoBold9pt7b.h>
#include <time.h>

// Network and API Configuration
const int MAX_NETWORKS = 2;
const char* ssid[MAX_NETWORKS] = {"*****", "*****"};
const char* password[MAX_NETWORKS] = {"*****", "*****"};
const char* apiKey = "*****";
const float latitude = 41.117199;  // for Ohrid
const float longitude = 20.801901; // for Ohrid

// Pin Definitions
#define PWR 7
#define BUSY 48
#define RES 47
#define DC 46
#define CS 45
#define BUTTON_PIN 2  // New: Button for display inversion

// Global Variables
RTC_DATA_ATTR bool rtcInvertDisplay = false;  // Persists across deep sleep
bool invertDisplay = false;  // Current display state

// Display Configuration
GxEPD2_BW<GxEPD2_420_GYE042A87, GxEPD2_420_GYE042A87::HEIGHT> epd(GxEPD2_420_GYE042A87(CS, DC, RES, BUSY));

const int screenW = 400, screenH = 300;
const int graphBottom = 278, graphTop = 160, graphHeight = graphBottom - graphTop;
const int todayTop = 20, todayBottom = 138, todayHeight = todayBottom - todayTop;
const int todayWidth = screenW / 2 - 40, todayX = screenW - todayWidth - 8;

const int currentWeatherLeft = 7;
const int currentWeatherRight = todayX - 19;
const int currentWeatherWidth = currentWeatherRight - currentWeatherLeft;
const int currentWeatherTop = todayTop;
const int currentWeatherHeight = todayHeight;

// Weather Data Storage
int lastUpdateHour = -1;
String currentWeatherDesc = "";
float currentTemp = 0.0;
float currentPressure = 0.0;
int currentHumidity = 0;
int currentDayIndex = 0;

String daysOfWeek[7] = {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"};
float hourlyRain[24] = {0};
float dailyRain[6][8] = {0};
int dailyPop[6] = {0};

bool connectToWiFi() {
  for (int i = 0; i < MAX_NETWORKS; i++) {
    Serial.printf("Trying WiFi %d/%d: %s\n", i+1, MAX_NETWORKS, ssid[i]);
    WiFi.begin(ssid[i], password[i]);
    
    unsigned long start = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - start < 15000) {
      delay(250);
      Serial.print(".");
    }
    
    if (WiFi.status() == WL_CONNECTED) {
      Serial.printf("\nConnected to %s\n", ssid[i]);
      Serial.print("IP Address: ");
      Serial.println(WiFi.localIP());
      return true;
    }
    Serial.println("\nConnection failed");
    WiFi.disconnect();
    delay(1000);
  }
  return false;
}

void epdPower(int state) {
  pinMode(PWR, OUTPUT);
  digitalWrite(PWR, state);
}

void epdInit() {
  epd.init(115200, true, 50, false);
  epd.setRotation(0);
  epd.setTextColor(invertDisplay ? GxEPD_WHITE : GxEPD_BLACK);
  epd.setFont(&FreeMonoBold9pt7b);
  epd.setFullWindow();
  Serial.println("E-paper initialized");
}

int currentHour() {
  struct tm timeinfo;
  if (getLocalTime(&timeinfo)) return timeinfo.tm_hour;
  return 0;
}

void drawCurrentWeatherBox(String weatherDesc, float temperature, float pressure, int humidity, int updateHour) {
  uint16_t fgColor = invertDisplay ? GxEPD_WHITE : GxEPD_BLACK;
  epd.setTextColor(fgColor);
  
  // Draw the box
  epd.drawRect(currentWeatherLeft, currentWeatherTop, currentWeatherWidth, currentWeatherHeight, fgColor);
  epd.drawRect(currentWeatherLeft+1, currentWeatherTop+1, currentWeatherWidth-2, currentWeatherHeight-2, fgColor);
  
  // Draw label
  epd.setCursor(currentWeatherLeft + 5, currentWeatherTop - 5);
  epd.print("Current Weather");

  // Draw divider lines (dashed)
  for (int i = 1; i <= 3; i++) {
    int y = currentWeatherTop + i * currentWeatherHeight / 4;
    for (int x = currentWeatherLeft + 1; x < currentWeatherRight - 1; x += 4) {
      epd.drawPixel(x, y, fgColor);
      epd.drawPixel(x+1, y, fgColor);
    }
  }

  // Calculate text positions
  int rowHeight = currentWeatherHeight / 4;
  int textYOffset = rowHeight / 2 + 5;

  // Row 1: Weather description
  epd.setCursor(currentWeatherLeft + 5, currentWeatherTop + rowHeight * 0 + textYOffset);
  epd.print(weatherDesc);

  // Row 2: Temperature
  epd.setCursor(currentWeatherLeft + 5, currentWeatherTop + rowHeight * 1 + textYOffset);
  epd.print("Temp: ");
  epd.print(temperature, 1);
  epd.print(" °C");

  // Row 3: Pressure
  epd.setCursor(currentWeatherLeft + 5, currentWeatherTop + rowHeight * 2 + textYOffset);
  epd.print("Press: ");
  epd.print(pressure, 1);
  epd.print(" hPa");

  // Row 4: Humidity and Update time
  epd.setCursor(currentWeatherLeft + 5, currentWeatherTop + rowHeight * 3 + textYOffset);
  epd.print("Humid: ");
  epd.print(humidity);
  epd.print(" %");
  
  char updateStr[10];
  sprintf(updateStr, "UT %d", updateHour);
  int textWidth = 6 * strlen(updateStr);
  epd.setCursor(currentWeatherRight - textWidth - 35, currentWeatherTop + rowHeight * 3 + textYOffset);
  epd.print(updateStr);
}

void drawTodayBox(int currentHour) {
  uint16_t fgColor = invertDisplay ? GxEPD_WHITE : GxEPD_BLACK;
  epd.setTextColor(fgColor);

  epd.setCursor(todayX + 5, todayTop - 5);
  epd.print(daysOfWeek[currentDayIndex]);

  // Show POP percentage
  epd.setCursor(todayX + todayWidth - 90, todayTop - 5);
  char popStr[10];
  sprintf(popStr, "POP %d%%", dailyPop[0]);
  epd.print(popStr);

  epd.drawRect(todayX, todayTop, todayWidth, todayHeight, fgColor);
  epd.drawRect(todayX + 1, todayTop + 1, todayWidth - 2, todayHeight - 2, fgColor);

  // Draw hour markers
  for (int h = 6; h <= 18; h += 6) {
    int x = todayX + map(h, 0, 24, 4, todayWidth - 4);
    for (int y = todayTop; y < todayBottom; y += 4)
      epd.drawPixel(x, y, fgColor);
  }

  // Draw horizontal grid lines
  for (int i = 1; i <= 3; i++) {
    int y = todayTop + i * todayHeight / 4;
    for (int x = todayX + 1; x < todayX + todayWidth - 1; x += 4)
      epd.drawPixel(x, y, fgColor);
  }

  float maxRain = 0.1;
  for (int i = currentHour; i < 24; i++)
    if (hourlyRain[i] > maxRain) maxRain = hourlyRain[i];
  float roundedMax = getRoundedMax(maxRain);

  epd.setCursor(todayX - 15, todayTop + 10);
  epd.print((int)(roundedMax));

  epd.setCursor(todayX - 15, todayBottom);
  epd.print("0");

  bool hasRain = false;
  for (int h = currentHour; h < 24; h++) {
    int barHeight = map(hourlyRain[h] * 10, 0, roundedMax * 10, 0, todayHeight - 5);
    if (barHeight > 0) {
      hasRain = true;
      int x = todayX + map(h, 0, 24, 4, todayWidth - 4);
      
   //   for (int w = 0; w < 10; w++) {
      for (int w = 0; w < 15; w++) {   // So Podebeli Barovi

        
        if (x + w < todayX + todayWidth - 1)
          epd.drawFastVLine(x + w, todayBottom - barHeight, barHeight, fgColor);
      }
    }
  }

  if (!hasRain) {
    int midX = todayX + todayWidth / 2 - 10;
    int midY = todayTop + todayHeight / 2;
    epd.setCursor(midX, midY - 5);
    epd.print("NO");
    epd.setCursor(midX-12, midY + 12);
    epd.print("RAIN");
  }
}

void drawWeekBoxes() {
  uint16_t fgColor = invertDisplay ? GxEPD_WHITE : GxEPD_BLACK;
  epd.setTextColor(fgColor);
  
  float maxRain = 0.1;
  for (int d = 1; d <= 5; d++) {
    for (int i = 0; i < 8; i++) {
      if (dailyRain[d][i] > maxRain) maxRain = dailyRain[d][i];
    }
  }
  
  float roundedMax = getRoundedMax(maxRain);
  Serial.print("Rounded max rain: "); Serial.println(roundedMax);

  int rectW = 70, gap = 6, startX = 19;
  const int topPadding = 5;
  const int usableGraphHeight = graphHeight - topPadding;

  // Only draw numerical labels without grid lines
  epd.setCursor(4, graphBottom);
  epd.print("0");
  epd.setCursor(4, graphTop + topPadding + 8);
  epd.print((int)roundedMax);

  // Draw boxes for next 5 days
  for (int d = 1; d <= 5; d++) {
    int boxIndex = d - 1;
    int x = startX + boxIndex * (rectW + gap);
    
    // Draw box outline
    epd.drawRect(x, graphTop, rectW, graphHeight, fgColor);
    epd.drawRect(x + 1, graphTop + 1, rectW - 2, graphHeight - 2, fgColor);

    // Day label
    epd.setCursor(x + 15, graphTop - 7);
    epd.print(daysOfWeek[(currentDayIndex + d) % 7]);

    // Vertical center line
    for (int y = graphTop; y < graphBottom; y += 4)
      epd.drawPixel(x + rectW / 2, y, fgColor);

    // Internal horizontal grid lines - only within each box
    for (int j = 1; j <= 3; j++) {
      int y = graphTop + j * graphHeight / 4;
      for (int i = x + 1; i < x + rectW - 1; i += 4)
        epd.drawPixel(i, y, fgColor);
    }

    // Draw rain bars
    bool hasRain = false;
    for (int i = 0; i < 8; i++) {
      float rainVal = dailyRain[d][i];
      int barHeight = map(rainVal * 10, 0, roundedMax * 10, 0, usableGraphHeight);
      if (barHeight > 0) {
        hasRain = true;
        int barX = x + 5 + i * 7;
        for (int w = 0; w < 5; w++)
          epd.drawFastVLine(barX + w, graphBottom - barHeight, barHeight, fgColor);
      }
    }

    // Show POP percentage
    char popStr[6];
    sprintf(popStr, "%d%%", dailyPop[d]);
    epd.setCursor(x + 20, graphBottom + 15);
    epd.print(popStr);

    // "NO RAIN" text if applicable
    if (!hasRain) {
      int midX = x + rectW / 2 - 10;
      int midY = graphTop + graphHeight / 2;
      epd.setCursor(midX, midY - 5);
      epd.print("NO");
      epd.setCursor(midX-12, midY + 12);
      epd.print("RAIN");
    }
  }
}

float getRoundedMax(float maxRain) {
  if (maxRain <= 0) return 1.0;
  if (maxRain <= 1.0) return 1.0;
  return ceil(maxRain);
}

bool fetchCurrentWeather(String &weatherDesc, float &temperature, float &pressure, int &humidity, int &updateHour) {
  WiFiClient client;
  HTTPClient http;
  String url = "http://api.openweathermap.org/data/2.5/weather?lat=" + String(latitude, 6) +
               "&lon=" + String(longitude, 6) + "&units=metric&appid=" + apiKey;
  
  http.begin(client, url);
  int httpCode = http.GET();

  if (httpCode == 200) {
    String payload = http.getString();
    DynamicJsonDocument doc(1024);
    DeserializationError error = deserializeJson(doc, payload);
    
    if (!error) {
      weatherDesc = doc["weather"][0]["description"].as<String>();
      weatherDesc.setCharAt(0, toupper(weatherDesc[0]));
      
      temperature = doc["main"]["temp"].as<float>();
      pressure = doc["main"]["pressure"].as<float>();
      humidity = doc["main"]["humidity"].as<int>();
      
      time_t updateTime = doc["dt"].as<time_t>();
      updateTime += 0;
      struct tm *timeinfo = localtime(&updateTime);
      updateHour = timeinfo->tm_hour;
      
      return true;
    }
  }
  http.end();
  return false;
}


void fetchForecastData() {
  Serial.println("Fetching forecast data...");
  WiFiClient client;
  HTTPClient http;
  String url = "http://api.openweathermap.org/data/2.5/forecast?lat=" + String(latitude, 6) +
               "&lon=" + String(longitude, 6) + "&units=metric&appid=" + apiKey;
  
  http.begin(client, url);
  int httpCode = http.GET();

  if (httpCode == 200) {
    String payload = http.getString();
    DynamicJsonDocument doc(50000);
    DeserializationError error = deserializeJson(doc, payload);
    
    if (!error) {
      JsonArray list = doc["list"];
      
      for (int d = 0; d < 6; d++) {
        dailyPop[d] = 0;
      }
      
      for (int i = 0; i < list.size(); i++) {
        JsonObject entry = list[i];
        const char* dt_txt = entry["dt_txt"];
        struct tm tm;
        strptime(dt_txt, "%Y-%m-%d %H:%M:%S", &tm);
        int dayIndex = (tm.tm_wday == 0 ? 6 : tm.tm_wday - 1);
        int dayOffset = (dayIndex - currentDayIndex + 7) % 7;

        float rain = 0.0;
        if (entry.containsKey("rain") && entry["rain"].containsKey("3h")) {
          rain = entry["rain"]["3h"].as<float>();
        }

        int pop = int(entry["pop"].as<float>() * 100);

        if (dayOffset == 0) {
          if (tm.tm_hour < 24) {
            hourlyRain[tm.tm_hour] = rain;
          }
          if (pop > dailyPop[0]) {
            dailyPop[0] = pop;
          }
        }
        else if (dayOffset >= 1 && dayOffset <= 5) {
          int slot = tm.tm_hour / 3;
          if (slot < 8) {
            dailyRain[dayOffset][slot] = rain;
            if (pop > dailyPop[dayOffset]) {
              dailyPop[dayOffset] = pop;
            }
          }
        }
      }

    }

  }
  http.end();
}


void syncTime() {
  Serial.println("Syncing time...");
  configTime(7200, 0, "pool.ntp.org");
  struct tm timeinfo;
  while (!getLocalTime(&timeinfo)) delay(100);
  currentDayIndex = timeinfo.tm_wday == 0 ? 6 : timeinfo.tm_wday - 1;
  Serial.print("Current hour: "); Serial.println(timeinfo.tm_hour);
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("Weather Display Booting...");

  // Setup button
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  
  // Load inversion state from RTC memory
  invertDisplay = rtcInvertDisplay;
  
  // Check button state during boot
  if (digitalRead(BUTTON_PIN) == LOW) {
    invertDisplay = !invertDisplay;
    rtcInvertDisplay = invertDisplay;  // Save to RTC memory
    delay(100);  // Debounce
  }

  // Initialize display
  epdPower(HIGH);
  epdInit();

  // Attempt WiFi connection
  bool wifiConnected = connectToWiFi();

  if (!wifiConnected) {
    Serial.println("Failed to connect to any network");
    currentWeatherDesc = "Offline";
    currentTemp = 0.0;
    currentPressure = 0.0;
    currentHumidity = 0;
    
    struct tm timeinfo;
    if (getLocalTime(&timeinfo)) {
      lastUpdateHour = timeinfo.tm_hour;
    } else {
      lastUpdateHour = 0;
    }
  } else {
    syncTime();
    fetchForecastData();
    
    String weatherDesc;
    float temperature, pressure;
    int humidity, updateHour;
    if (fetchCurrentWeather(weatherDesc, temperature, pressure, humidity, updateHour)) {
      currentWeatherDesc = weatherDesc;
      currentTemp = temperature;
      currentPressure = pressure;
      currentHumidity = humidity;
      lastUpdateHour = updateHour;
    }
  }

  // Draw display
  Serial.println("Drawing to e-paper...");
  epd.fillScreen(invertDisplay ? GxEPD_BLACK : GxEPD_WHITE);
  epd.drawRect(0, 0, screenW, screenH, invertDisplay ? GxEPD_WHITE : GxEPD_BLACK);
  
  drawCurrentWeatherBox(currentWeatherDesc, currentTemp, currentPressure, currentHumidity, lastUpdateHour);
  drawTodayBox(currentHour());
  drawWeekBoxes();
  
  epd.display();
  epd.hibernate();
  epdPower(LOW);
 

  Serial.println("Entering deep sleep...");
  esp_sleep_enable_ext0_wakeup(GPIO_NUM_2, 0); // Wake on button press
  esp_sleep_enable_timer_wakeup(900LL * 1000000); // 15 min
  esp_deep_sleep_start();
}

void loop() {
  // Empty - device will be in deep sleep
} 

EVAL BOARD FOR ESP32
Mfr Part # DIE07300S001
EVAL BOARD FOR ESP32
Elecrow
$ 30,06
View More Details
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.