Weather Display Screen for Closet
I’ve been wanting a simple weather forecast display, and decided to build one.
These days, I call a high-rise condominium near Seattle’s Pike Place Market home.
But I didn’t want an always-on OLED display. So instead of heading down to test the weather with palm-up, I thought it’d be much more convenient to glance at recommended apparel suggestions in the morning.
I’ve just built such a display, using an ESP32-powered e-paper device.
ESP-32 brings you WiFi built in, so fetching and displaying the data is easy, as long as you parse the JSON properly from an Application Programming Interface (API.)
Every fifteen minutes, this thing hits OpenWeather to grab the forecast and then builds a suggestion, ranging from shorts to winter parka. It reports air quality, and forecast precipitation.
Since it’s an e-paper display, the project draws power very sparingly. Power is only drawn every fifteen minutes, to fetch the forecast and update the display. This leads to longer-lasting battery life.
The whole build cost less than $70.
Parts:
Elecrow 4.2″ display (currently $43.69 on Amazon)
SH1.0 2-pin connector (optional)
3.7V battery (3000mAh) (optional)
Custom case, ordered from a 3D printing service (optional.)
Major Gotcha: Use the Right Driver!
The Elecrow panel technically worked with the OEM software out of the box, but any kind of code uploaded to it would not display on-screen.
The reason is that Elecrow is using two different ESP32 chips in the shipment, and I had to switch to the “green dot” version to get anything to work.
See this thread on Elecrow’s support forum.
In fact I returned two CrowPanel 4.2″ e-paper displays back before I figured out the hardware was fine all along, just very poorly documented.
The symptom was simple and maddening. The ESP32-S3 flashed perfectly. Serial output was clean, the uploads worked, but the screen never changed. It sat frozen on whatever the factory demo had painted at the warehouse, and nothing I compiled would tell the screen to update.
The wrong conclusion
When a board flashes fine but the display stays dead, the obvious suspect is the display. I reseated the ribbon cable a dozen times. I flashed Elecrow’s factory firmware back onto it. I tried both pin sets in their driver header. I requested a replacement, got the same behavior, and requested another.
I was not alone. This Elecrow forum thread had five people describing the identical failure across roughly four weeks, with no working answer from support. One user got their panel painting again by flashing something they called the “green dot” firmware but could not compile their own code against it. That was the clue.
Turns out Elecrow shipped a hardware revision with a different display controller and did not update the documentation.
The wiki still lists the driver chip as SSD1683. Newer panels, marked with a small green sticker on the back, use a UC8276C instead. Those two chips speak completely different languages.
I downloaded Elecrow’s repository and diffed the two driver files. The original EPD_Init() writes registers 0x12, 0x21, 0x3C, 0x11, which is the SSD16xx command set. The green-sticker version writes 0x00, 0x01, 0x06, 0x30, 0x61, 0x82, 0x50, 0x60, 0xE3, which is the UC81xx family. Different silicon, different initialization, different everything. Worse, the old driver’s refresh routine writes image data to register 0x24 and triggers via 0x22 and 0x20. On a UC81xx part, registers 0x20 through 0x24 are the waveform lookup tables. So the old code was dumping 15,000 bytes of bitmap into the panel’s LUT registers and then poking two more LUT registers as a trigger. No image data ever reached the frame buffer, and no refresh command was ever sent.
The fix
Skip Elecrow’s driver entirely and use GxEPD2, which already supports this controller:
cpp
GxEPD2_BW<GxEPD2_420_SE0420NQ04, GxEPD2_420_SE0420NQ04::HEIGHT>
display(GxEPD2_420_SE0420NQ04(/*CS=*/45, /*DC=*/46, /*RST=*/47, /*BUSY=*/48));
void setup() {
pinMode(41, OUTPUT); digitalWrite(41, HIGH); // green revision needs this rail
pinMode(7, OUTPUT); digitalWrite(7, HIGH);
SPI.begin(12, -1, 11, 45); // board pins, not S3 defaults
display.init(115200, true, 2, false);
display.epd2.selectSPI(SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
}Code language: JavaScript (javascript)
Three steps to fix it:
GxEPD2_420_SE0420NQ04is the UC8276C class.GxEPD2_420_GYE042A87is the SSD1683 one, for older boards.- GPIO41 is a power rail on the green revision. Elecrow’s own PWR example toggles it, but their older sketches never touch it.
- The board puts SCK on GPIO12 and MOSI on GPIO11, which are not the ESP32-S3 defaults. Without the remap, GxEPD2 clocks data out on the wrong pins and fails silently.
Full Code
You’ll need two files.
“secrets_home.h” file:
const char* WIFI_SSID = "<YOUR_WIFI_NETWORK_NAME_HERE>";
const char* WIFI_PASS = "<YOUR_WIFI_PASSWORD_HERE>";
const char* OWM_KEY = "<GO_TO_OPENWEATHER_AND_GET_A_FREE_API_KEY_AND_PASTE_HERE>";Code language: JavaScript (javascript)
“CrowPanel42_Weather.ino”:
Be sure to update the Lat & Long and city name to your address. (You can get your own lat/long from Google Maps.)
/*
* CrowPanel42_Weather.ino
*
* Elecrow CrowPanel ESP32-S3 4.2" E-Paper (400x300, DIE07300S)
* green-sticker / UC8276C revision, via GxEPD2. Battery + deep sleep.
*
* Shows: what to wear (headline), then today and tomorrow side by side.
*
* The forecast endpoint returns 3-hour buckets. We pull 16 of them (48h)
* in one call and bucket them into local calendar days. Still free tier,
* still three API calls per wake.
*
* Honest labelling: the left card says REST OF TODAY, because by evening
* today's high already happened and claiming otherwise would be wrong.
* After ~6pm the wear advice switches to tomorrow and says so.
*
* Libraries: GxEPD2, ArduinoJson v7
* Board: ESP32S3 Dev Module, 8MB flash, OPI PSRAM, Huge APP partition.
* Works on esp32 core 2.x and 3.x (watchdog init is version-guarded).
*/
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <SPI.h>
#include <GxEPD2_BW.h>
#include <esp_task_wdt.h>
#include <time.h>
#include <Fonts/FreeSans9pt7b.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSansBold12pt7b.h>
#include <Fonts/FreeSansBold18pt7b.h>
// ===== SECRETS =============================================================
// Credentials live in secrets_home.h, kept out of version control.
// That file must sit in this sketch folder and define:
// const char* WIFI_SSID = "...";
// const char* WIFI_PASS = "...";
// const char* OWM_KEY = "...";
#if !__has_include("secrets_home.h")
#error "secrets_home.h not found. Create it in the sketch folder defining WIFI_SSID, WIFI_PASS and OWM_KEY."
#endif
#include "secrets_home.h"
// ===== USER CONFIG =========================================================
// Location and timezone are not secret, so they stay here.
const char* OWM_LAT = "47.6062"; // Seattle
const char* OWM_LON = "-122.3321";
const char* OWM_UNITS = "imperial"; // or "metric"
const char* TZ_STRING = "PST8PDT,M3.2.0,M11.1.0";
#define MORNING_START_HR 5
#define MORNING_END_HR 10
#define NIGHT_START_HR 22
#define WAKE_MORNING_MIN 15
#define WAKE_DAY_MIN 15
#define WAKE_NIGHT_MIN 15
#define ADVICE_FLIP_HR 18 // past this hour, advise for tomorrow
// How to collapse a temperature range into one "what to wear" number.
// 0.0 dresses for the coldest moment, 1.0 for the warmest. 0.40 leans warm
// because you are mostly outdoors in the warm part of the day.
// DRESS_BIAS_F shifts every band at once: raise it to dress lighter.
#define DRESS_WARM_WEIGHT 0.40f
#define DRESS_BIAS_F 0.0f
#define STALE_WARN_HOURS 3
#define WDT_SECONDS 60
#define NTP_RESYNC_HOURS 24
// ===========================================================================
#define BATT_PIN -1 // no published ADC pin; set if you meter it
#define BATT_RATIO 2.0f
#define EPD_CS 45
#define EPD_DC 46
#define EPD_RST 47
#define EPD_BUSY 48
#define EPD_SCK 12
#define EPD_MOSI 11
#define EPD_MISO -1
#define PWR_MAIN 41
#define PWR_EPD 7
GxEPD2_BW<GxEPD2_420_SE0420NQ04, GxEPD2_420_SE0420NQ04::HEIGHT>
display(GxEPD2_420_SE0420NQ04(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));
struct DayFc {
float hi, lo, feelsMin, feelsMax;
int pop, wxid, count;
char label[12];
};
// Declared here, not next to buildAdvice(): the Arduino IDE injects
// generated prototypes above the first function definition, so any
// user type used in a return value must be declared before that point.
struct Advice { char line[2][40]; int n; bool forTomorrow; };
// ---- persists across deep sleep -------------------------------------------
RTC_DATA_ATTR uint32_t bootCount = 0;
RTC_DATA_ATTR time_t lastGoodFetch = 0;
RTC_DATA_ATTR time_t lastNtpSync = 0;
RTC_DATA_ATTR uint16_t failStreak = 0;
RTC_DATA_ATTR float r_temp, r_feels, r_wind;
RTC_DATA_ATTR int r_humidity, r_aqi, r_wxid;
RTC_DATA_ATTR time_t r_sunrise, r_sunset;
RTC_DATA_ATTR char r_cond[24];
RTC_DATA_ATTR char r_city[28];
RTC_DATA_ATTR bool r_isDay;
RTC_DATA_ATTR bool r_haveData = false;
RTC_DATA_ATTR DayFc r_d0, r_d1; // today-remaining, tomorrow
bool fetchOK = false;
bool imperial() { return strcmp(OWM_UNITS, "imperial") == 0; }
float toF(float v) { return imperial() ? v : v * 9.0f / 5.0f + 32.0f; }
float toMph(float v) { return imperial() ? v : v * 2.23694f; }
// ---------------------------------------------------------------------------
// Icons
// ---------------------------------------------------------------------------
void icoSun(int x, int y, int s) {
int cx = x + s / 2, cy = y + s / 2, r = s / 5;
display.fillCircle(cx, cy, r, GxEPD_BLACK);
for (int i = 0; i < 8; i++) {
float a = i * PI / 4;
int x1 = cx + cos(a) * (r + s / 10), y1 = cy + sin(a) * (r + s / 10);
int x2 = cx + cos(a) * (r + s / 3.2), y2 = cy + sin(a) * (r + s / 3.2);
display.drawLine(x1, y1, x2, y2, GxEPD_BLACK);
display.drawLine(x1 + 1, y1, x2 + 1, y2, GxEPD_BLACK);
}
}
void icoMoon(int x, int y, int s) {
int cx = x + s / 2, cy = y + s / 2, r = s / 3;
display.fillCircle(cx, cy, r, GxEPD_BLACK);
display.fillCircle(cx + r / 2, cy - r / 3, r, GxEPD_WHITE);
}
// Cloud silhouette. "shrink" pulls every primitive inward, which is how the
// outline is made: draw the full body black, then the shrunk body white.
// Eroding the filled shape gives a clean, even stroke; composing arcs by
// hand leaves the internal circle edges visible.
void cloudBody(int x, int y, int s, int shrink, uint16_t c) {
int rs = s / 4 - shrink; if (rs < 1) rs = 1;
int rc = (int)(s * 0.30f) - shrink; if (rc < 1) rc = 1;
int rh = s / 4 - shrink; if (rh < 1) rh = 1;
display.fillCircle(x + s / 4, y + s / 2, rs, c);
display.fillCircle(x + s / 2, y + s * 2 / 5, rc, c);
display.fillCircle(x + 3 * s / 4, y + s / 2, rs, c);
display.fillRect(x + s / 4, y + s / 2, s / 2, rh, c);
}
int cloudStroke(int s) { int t = s / 24; return t < 2 ? 2 : t; }
void icoCloud(int x, int y, int s) {
cloudBody(x, y, s, 0, GxEPD_BLACK);
cloudBody(x, y, s, cloudStroke(s), GxEPD_WHITE);
}
void icoPartly(int x, int y, int s) {
icoSun(x + s / 10, y - s / 10, (int)(s * 0.55f));
cloudBody(x, y + s / 4, s, 0, GxEPD_WHITE); // knock the sun out behind
icoCloud(x, y + s / 4, s);
}
void icoRain(int x, int y, int s) {
icoCloud(x, y - s / 10, s);
for (int i = 0; i < 3; i++) {
int rx = x + s / 3 + i * s / 6, ry = y + (int)(s * 0.70f);
display.drawLine(rx, ry, rx - s / 14, ry + s / 6, GxEPD_BLACK);
display.drawLine(rx + 1, ry, rx - s / 14 + 1, ry + s / 6, GxEPD_BLACK);
}
}
void icoSnow(int x, int y, int s) {
icoCloud(x, y - s / 10, s);
for (int i = 0; i < 3; i++) {
int cx = x + s / 3 + i * s / 6, cy = y + (int)(s * 0.80f);
int d = s / 18; if (d < 2) d = 2;
int e = (d * 7) / 10;
display.drawLine(cx - d, cy, cx + d, cy, GxEPD_BLACK);
display.drawLine(cx, cy - d, cx, cy + d, GxEPD_BLACK);
display.drawLine(cx - e, cy - e, cx + e, cy + e, GxEPD_BLACK);
display.drawLine(cx - e, cy + e, cx + e, cy - e, GxEPD_BLACK);
}
}
void icoStorm(int x, int y, int s) {
icoCloud(x, y - s / 10, s);
int bx = x + s / 2, by = y + (int)(s * 0.68f);
display.fillTriangle(bx + s / 14, by, bx - s / 10, by + s / 5,
bx, by + s / 5, GxEPD_BLACK);
display.fillTriangle(bx, by + s / 5, bx + s / 8, by + s / 6,
bx - s / 14, by + (int)(s / 2.4f), GxEPD_BLACK);
}
void icoFog(int x, int y, int s) {
for (int i = 0; i < 4; i++) {
int yy = y + s / 4 + i * s / 6, in = (i % 2) ? s / 8 : 0;
display.fillRect(x + in, yy, s - in * 2, s / 24 + 1, GxEPD_BLACK);
}
}
void drawIcon(int id, bool day, int x, int y, int s) {
if (id >= 200 && id < 300) icoStorm(x, y, s);
else if (id >= 300 && id < 600) icoRain(x, y, s);
else if (id >= 600 && id < 700) icoSnow(x, y, s);
else if (id >= 700 && id < 800) icoFog(x, y, s);
else if (id == 800) day ? icoSun(x, y, s) : icoMoon(x, y, s);
else if (id == 801 || id == 802) icoPartly(x, y, s);
else icoCloud(x, y, s);
}
// A day gets the most significant condition in it, not the average one.
int severity(int id) {
if (id >= 200 && id < 300) return 6; // thunderstorm
if (id >= 600 && id < 700) return 5; // snow
if (id >= 500 && id < 600) return 4; // rain
if (id >= 300 && id < 400) return 3; // drizzle
if (id >= 700 && id < 800) return 2; // fog / haze
if (id > 800) return 1; // clouds
return 0; // clear
}
const char* condWord(int id) {
if (id >= 200 && id < 300) return "Storms";
if (id >= 300 && id < 400) return "Drizzle";
if (id >= 500 && id < 600) return "Rain";
if (id >= 600 && id < 700) return "Snow";
if (id >= 700 && id < 800) return "Fog";
if (id == 800) return "Clear";
if (id == 801 || id == 802) return "Part cloudy";
return "Cloudy";
}
// ---------------------------------------------------------------------------
// Clothing advice. Thresholds are a starting point; tune to how you run.
// ---------------------------------------------------------------------------
Advice buildAdvice() {
Advice a; a.n = 0; a.forTomorrow = false;
const DayFc* d = &r_d0;
struct tm t;
if (getLocalTime(&t, 100) && (t.tm_hour >= ADVICE_FLIP_HR || r_d0.count < 2)) {
if (r_d1.count > 0) { d = &r_d1; a.forTomorrow = true; }
}
float lo = toF(d->feelsMin), hi = toF(d->feelsMax);
float wind = toMph(r_wind);
// Dress for a weighted point in the range, not its floor. Keying off the
// minimum put a light layer on an 87/68 day, which is over-dressed.
float dress = lo + DRESS_WARM_WEIGHT * (hi - lo) + DRESS_BIAS_F;
Serial.printf("advice: feels %.0f-%.0f -> dress %.0f\n", lo, hi, dress);
const char* core;
if (dress >= 78) core = "Shorts and a tee";
else if (dress >= 68) core = "Tee, no layer needed";
else if (dress >= 58) core = "Long sleeve or light layer";
else if (dress >= 48) core = "Light jacket";
else if (dress >= 38) core = "Jacket over a layer";
else if (dress >= 30) core = "Warm coat, bring a hat";
else core = "Heavy coat, hat, gloves";
strlcpy(a.line[a.n++], core, 40);
if (d->pop >= 60) strlcpy(a.line[a.n++], "Rain shell and umbrella", 40);
else if (d->pop >= 30) strlcpy(a.line[a.n++], "Pack an umbrella", 40);
else if ((hi - lo) >= 16) strlcpy(a.line[a.n++], "Big swing, wear layers", 40);
else if (wind >= 20) strlcpy(a.line[a.n++], "Windy, windproof outer", 40);
else if (r_aqi >= 4) strlcpy(a.line[a.n++], "Poor air, mask if out long", 40);
return a;
}
// ---------------------------------------------------------------------------
// ESP32's newlib strftime does not implement the GNU "-" no-padding flag
// (%-d, %-I). It stops emitting at the first unsupported specifier, which
// silently truncates the string. Format from struct tm fields instead.
static const char* kWday[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
static const char* kMon[] = {"Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"};
void fmtClock(char* out, size_t n, const struct tm& t) {
int h = t.tm_hour % 12; if (h == 0) h = 12;
snprintf(out, n, "%d:%02d %s", h, t.tm_min, t.tm_hour < 12 ? "AM" : "PM");
}
void fmtStamp(char* out, size_t n, const struct tm& t) {
int h = t.tm_hour % 12; if (h == 0) h = 12;
snprintf(out, n, "%s %s %d, %d:%02d %s",
kWday[t.tm_wday % 7], kMon[t.tm_mon % 12], t.tm_mday,
h, t.tm_min, t.tm_hour < 12 ? "AM" : "PM");
}
// Draw at the largest of these sizes that fits maxW, so the wear advice
// never overflows or wraps into the line beneath it.
void fitText(const char* s, int x, int by, int maxW) {
const GFXfont* fonts[] = { &FreeSansBold12pt7b, &FreeSansBold9pt7b };
int16_t bx, byy; uint16_t bw, bh;
for (uint8_t i = 0; i < 2; i++) {
display.setFont(fonts[i]);
display.getTextBounds(s, 0, 0, &bx, &byy, &bw, &bh);
if (bw <= maxW || i == 1) break;
}
display.setCursor(x - bx, by); // align ink, not pen
display.print(s);
}
// Adafruit GFX draws each glyph at cursor + the glyph's xOffset (left side
// bearing), which is 0-2px depending on font AND on which letter starts the
// string. Identical setCursor(10,..) calls therefore produce a ragged left
// edge that even shifts as the text changes. Compensate with the bounds
// offset so the INK always starts exactly at x. Set the font before calling.
#define LEFT_X 10
void leftText(const char* s, int x, int by) {
int16_t bx, byy; uint16_t bw, bh;
display.getTextBounds(s, 0, 0, &bx, &byy, &bw, &bh);
display.setCursor(x - bx, by);
display.print(s);
}
void rightText(const char* s, int rx, int by) {
int16_t bx, byy; uint16_t bw, bh;
display.getTextBounds(s, 0, 0, &bx, &byy, &bw, &bh);
display.setCursor(rx - bw, by); display.print(s);
}
void centerText(const char* s, int cx, int by) {
int16_t bx, byy; uint16_t bw, bh;
display.getTextBounds(s, 0, 0, &bx, &byy, &bw, &bh);
display.setCursor(cx - bw / 2, by); display.print(s);
}
const char* aqiWord(int a) {
switch (a) { case 1: return "Good"; case 2: return "Fair"; case 3: return "Moderate";
case 4: return "Poor"; case 5: return "V.poor"; default: return "--"; }
}
int hoursStale() {
if (!lastGoodFetch) return 9999;
time_t n = time(nullptr);
if (n < 1600000000) return 0;
return (int)((n - lastGoodFetch) / 3600);
}
void drawCard(const DayFc& d, int cx, int top) {
char b[40];
const char* dgr = imperial() ? "F" : "C";
display.setFont(&FreeSansBold9pt7b);
centerText(d.label, cx, top + 14);
if (d.count == 0) {
display.setFont(&FreeSans9pt7b);
centerText("no data", cx, top + 60);
return;
}
drawIcon(d.wxid, true, cx - 78, top + 22, 56);
display.setFont(&FreeSansBold18pt7b);
snprintf(b, sizeof(b), "%.0f/%.0f", d.hi, d.lo);
display.setCursor(cx - 16, top + 58);
display.print(b);
display.setFont(&FreeSans9pt7b);
display.setCursor(cx - 16, top + 78);
display.print(condWord(d.wxid));
snprintf(b, sizeof(b), "rain %d%%", d.pop);
display.setCursor(cx - 16, top + 96);
display.print(b);
}
void render() {
const char* dgr = imperial() ? "F" : "C";
const char* sp = imperial() ? "mph" : "m/s";
char b[64], t1[24], t2[16];
int stale = hoursStale();
display.setFullWindow();
display.firstPage();
do {
display.fillScreen(GxEPD_WHITE);
display.setTextColor(GxEPD_BLACK);
// header
display.setFont(&FreeSansBold9pt7b);
leftText(r_haveData ? r_city : "No data", LEFT_X, 18);
display.setFont(&FreeSans9pt7b);
if (lastGoodFetch) {
struct tm lt; localtime_r(&lastGoodFetch, <);
fmtStamp(t1, sizeof(t1), lt);
snprintf(b, sizeof(b), "as of %s", t1);
} else snprintf(b, sizeof(b), "never updated");
rightText(b, 390, 18);
display.drawLine(0, 25, 400, 25, GxEPD_BLACK);
if (!r_haveData) {
display.setFont(&FreeSansBold12pt7b);
leftText("Waiting for first fetch", LEFT_X, 80);
display.setFont(&FreeSans9pt7b);
leftText("Check WiFi and API key", LEFT_X, 108);
return;
}
// headline advice
// Wrap is off so a long string can never collide with the line below.
// fitText picks the largest font that actually fits the width.
Advice a = buildAdvice();
display.setTextWrap(false);
fitText(a.line[0], LEFT_X, 50, 300);
display.setFont(&FreeSans9pt7b);
if (a.n > 1) leftText(a.line[1], LEFT_X, 70);
if (a.forTomorrow) rightText("for tomorrow", 390, 70);
display.setTextWrap(true);
display.drawLine(0, 80, 400, 80, GxEPD_BLACK);
// two forecast cards
drawCard(r_d0, 100, 84);
drawCard(r_d1, 300, 84);
display.drawLine(200, 84, 200, 196, GxEPD_BLACK);
display.drawLine(0, 196, 400, 196, GxEPD_BLACK);
// now strip
display.setFont(&FreeSansBold12pt7b);
snprintf(b, sizeof(b), "Now %.0f%s", r_temp, dgr);
leftText(b, LEFT_X, 220);
display.setFont(&FreeSans9pt7b);
snprintf(b, sizeof(b), "feels %.0f%s %s", r_feels, dgr, r_cond);
display.setCursor(120, 220);
display.print(b);
// Wind left, AQI right-aligned. Humidity was dropped from this line:
// "Wind 12 m/s Humidity 100% AQI 5/5 (Moderate)" measures 15px past
// the 400px panel in the worst case and would wrap into the sun line.
snprintf(b, sizeof(b), "Wind %.0f %s", r_wind, sp);
leftText(b, LEFT_X, 246);
// OpenWeather's AQI is a 1-5 index, NOT the US EPA 0-500 scale. The
// denominator keeps "2" from reading as a US AQI of 2.
snprintf(b, sizeof(b), "AQI %d/5 (%s)", r_aqi, aqiWord(r_aqi));
rightText(b, 390, 246);
struct tm sr, ss;
localtime_r(&r_sunrise, &sr); fmtClock(t1, sizeof(t1), sr);
localtime_r(&r_sunset, &ss); fmtClock(t2, sizeof(t2), ss);
snprintf(b, sizeof(b), "Sunrise %s Sunset %s", t1, t2);
leftText(b, LEFT_X, 268);
if (BATT_PIN >= 0) {
float v = analogReadMilliVolts(BATT_PIN) * BATT_RATIO / 1000.0f;
snprintf(b, sizeof(b), "%.2fV #%u", v, bootCount);
} else snprintf(b, sizeof(b), "#%u", bootCount);
if (failStreak) snprintf(b + strlen(b), sizeof(b) - strlen(b), " offline x%u", failStreak);
rightText(b, 390, 268);
// staleness banner, deliberately unmissable
if (stale >= STALE_WARN_HOURS) {
display.fillRect(0, 274, 400, 26, GxEPD_BLACK);
display.setTextColor(GxEPD_WHITE);
display.setFont(&FreeSansBold12pt7b);
if (stale > 240) snprintf(b, sizeof(b), "DATA VERY OLD - CHECK BATTERY");
else snprintf(b, sizeof(b), "%dh OLD - CHECK BATTERY", stale);
centerText(b, 200, 293);
display.setTextColor(GxEPD_BLACK);
}
} while (display.nextPage());
}
// ---------------------------------------------------------------------------
bool getJson(const String& url, JsonDocument& doc, JsonDocument* filter = nullptr) {
HTTPClient http;
http.setTimeout(8000); http.setConnectTimeout(8000);
if (!http.begin(url)) return false;
int code = http.GET();
if (code != 200) {
Serial.printf(" HTTP %d\n", code);
if (code == 401) Serial.println(" 401: key inactive (up to 1h) or wrong");
http.end(); return false;
}
DeserializationError e = filter
? deserializeJson(doc, http.getStream(), DeserializationOption::Filter(*filter))
: deserializeJson(doc, http.getStream());
http.end();
if (e) { Serial.printf(" JSON %s\n", e.c_str()); return false; }
return true;
}
String base(const char* path, bool units) {
String u = String("http://api.openweathermap.org/data/2.5/") + path
+ "?lat=" + OWM_LAT + "&lon=" + OWM_LON;
if (units) u += String("&units=") + OWM_UNITS;
return u + "&appid=" + OWM_KEY;
}
void resetDay(DayFc& d, const char* label) {
d.hi = -1e9; d.lo = 1e9; d.feelsMin = 1e9; d.feelsMax = -1e9;
d.pop = 0; d.wxid = 800; d.count = 0;
strlcpy(d.label, label, sizeof(d.label));
}
void accumulate(DayFc& d, float mx, float mn, float fl, int pop, int id) {
if (mx > d.hi) d.hi = mx;
if (mn < d.lo) d.lo = mn;
if (fl < d.feelsMin) d.feelsMin = fl;
if (fl > d.feelsMax) d.feelsMax = fl;
if (pop > d.pop) d.pop = pop;
if (d.count == 0 || severity(id) > severity(d.wxid)) d.wxid = id;
d.count++;
}
bool fetchAll() {
JsonDocument doc;
Serial.println("fetch: current");
if (!getJson(base("weather", true), doc)) return false;
r_temp = doc["main"]["temp"] | 0.0f;
r_feels = doc["main"]["feels_like"] | 0.0f;
r_humidity = doc["main"]["humidity"] | 0;
r_wind = doc["wind"]["speed"] | 0.0f;
r_wxid = doc["weather"][0]["id"] | 800;
r_sunrise = doc["sys"]["sunrise"] | 0;
r_sunset = doc["sys"]["sunset"] | 0;
strlcpy(r_cond, doc["weather"][0]["main"] | "--", sizeof(r_cond));
strlcpy(r_city, doc["name"] | "--", sizeof(r_city));
const char* ic = doc["weather"][0]["icon"] | "01d";
r_isDay = (strlen(ic) >= 3 && ic[2] == 'd');
// 48 hours of 3-hour buckets, filtered to just the fields we use so the
// JSON document stays small.
Serial.println("fetch: forecast 48h");
JsonDocument filter;
JsonObject fe = filter["list"].add<JsonObject>();
fe["dt"] = true;
fe["main"]["temp_max"] = true;
fe["main"]["temp_min"] = true;
fe["main"]["feels_like"] = true;
fe["pop"] = true;
fe["weather"][0]["id"] = true;
JsonDocument f;
if (getJson(base("forecast", true) + "&cnt=16", f, &filter)) {
struct tm now;
int todayYday = -1;
if (getLocalTime(&now, 100)) todayYday = now.tm_yday;
DayFc d0, d1;
resetDay(d0, "TODAY");
resetDay(d1, "TOMORROW");
for (JsonObject e : f["list"].as<JsonArray>()) {
time_t dt = e["dt"] | 0;
if (!dt) continue;
struct tm lt; localtime_r(&dt, <);
float mx = e["main"]["temp_max"] | 0.0f;
float mn = e["main"]["temp_min"] | 0.0f;
float fl = e["main"]["feels_like"] | 0.0f;
int p = (int)roundf(((float)(e["pop"] | 0.0f)) * 100.0f);
int id = e["weather"][0]["id"] | 800;
if (todayYday < 0) continue;
if (lt.tm_yday == todayYday) accumulate(d0, mx, mn, fl, p, id);
else if (lt.tm_yday == todayYday + 1 ||
(todayYday >= 364 && lt.tm_yday == 0))
accumulate(d1, mx, mn, fl, p, id);
}
// By evening there is little or no "today" left. Say so rather than
// presenting a stub as if it were the whole day.
if (d0.count == 0) {
resetDay(d0, "TODAY");
d0.hi = r_temp; d0.lo = r_temp;
d0.feelsMin = r_feels; d0.feelsMax = r_feels;
d0.wxid = r_wxid; d0.count = 1;
strlcpy(d0.label, "NOW", sizeof(d0.label));
} else if (d0.count <= 2) {
strlcpy(d0.label, "REST TODAY", sizeof(d0.label));
}
if (d0.count) r_d0 = d0;
if (d1.count) r_d1 = d1;
}
Serial.println("fetch: air quality");
JsonDocument a;
if (getJson(base("air_pollution", false), a))
r_aqi = a["list"][0]["main"]["aqi"] | 0;
r_haveData = true;
Serial.printf("today %.0f/%.0f pop%d tomorrow %.0f/%.0f pop%d\n",
r_d0.hi, r_d0.lo, r_d0.pop, r_d1.hi, r_d1.lo, r_d1.pop);
return true;
}
int nextWakeMinutes() {
struct tm t;
if (!getLocalTime(&t, 100)) return WAKE_DAY_MIN;
int h = t.tm_hour;
if (h >= MORNING_START_HR && h < MORNING_END_HR) return WAKE_MORNING_MIN;
if (h >= NIGHT_START_HR || h < MORNING_START_HR) return WAKE_NIGHT_MIN;
return WAKE_DAY_MIN;
}
void sleepNow() {
int mins = nextWakeMinutes();
Serial.printf("awake %lums, sleeping %d min\n", millis(), mins);
Serial.flush();
display.hibernate();
digitalWrite(PWR_EPD, LOW);
digitalWrite(PWR_MAIN, LOW);
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
esp_sleep_enable_timer_wakeup((uint64_t)mins * 60ULL * 1000000ULL);
esp_deep_sleep_start();
}
void setup() {
Serial.begin(115200);
delay(300);
bootCount++;
Serial.printf("\n=== wake #%u ===\n", bootCount);
if (bootCount == 1) { resetDay(r_d0, "TODAY"); resetDay(r_d1, "TOMORROW"); }
// esp32 core 3.x replaced the old (seconds, panic) signature with a
// config struct, and may already have started the WDT at boot.
#if ESP_ARDUINO_VERSION_MAJOR >= 3
esp_task_wdt_config_t wdtCfg = {
.timeout_ms = (uint32_t)WDT_SECONDS * 1000,
.idle_core_mask = 0,
.trigger_panic = true,
};
if (esp_task_wdt_init(&wdtCfg) == ESP_ERR_INVALID_STATE)
esp_task_wdt_reconfigure(&wdtCfg);
#else
esp_task_wdt_init(WDT_SECONDS, true);
#endif
esp_task_wdt_add(NULL);
setenv("TZ", TZ_STRING, 1); tzset();
pinMode(PWR_MAIN, OUTPUT); digitalWrite(PWR_MAIN, HIGH);
pinMode(PWR_EPD, OUTPUT); digitalWrite(PWR_EPD, HIGH);
delay(80);
SPI.begin(EPD_SCK, EPD_MISO, EPD_MOSI, EPD_CS);
display.init(115200, true, 2, false);
display.epd2.selectSPI(SPI, SPISettings(4000000, MSBFIRST, SPI_MODE0));
display.setRotation(0);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
Serial.print("wifi ");
while (WiFi.status() != WL_CONNECTED && millis() < 20000) {
delay(250); Serial.print("."); esp_task_wdt_reset();
}
Serial.println(WiFi.status() == WL_CONNECTED ? " ok" : " FAILED");
if (WiFi.status() == WL_CONNECTED) {
time_t nowT = time(nullptr);
bool needNtp = (lastNtpSync == 0) || (nowT < 1600000000) ||
(nowT - lastNtpSync > NTP_RESYNC_HOURS * 3600);
for (int i = 0; i < 3 && needNtp; i++) {
configTzTime(TZ_STRING, "pool.ntp.org", "time.nist.gov", "time.google.com");
struct tm ti;
if (getLocalTime(&ti, 8000)) { lastNtpSync = time(nullptr); needNtp = false; }
esp_task_wdt_reset();
}
Serial.println(needNtp ? "ntp failed, using RTC" : "ntp ok");
fetchOK = fetchAll();
esp_task_wdt_reset();
}
if (fetchOK) { lastGoodFetch = time(nullptr); failStreak = 0; }
else { failStreak++; }
render();
esp_task_wdt_reset();
sleepNow();
}
void loop() { delay(100); }
Code language: PHP (php)
The Finished Product
Now I’ve got a handy weather display for my closet!
It wakes on a timer (every 15 minutes during early and waking hours, every 3 hours in the middle of the night.)
It pulls current conditions, gets a 48 hour forecast and air quality from OpenWeather’s free tier, and renders what to wear in large type at the top, with today and tomorrow side by side underneath.
It’s a useful new display which will help me choose the right thing to wear, before I even step outside.
