Community project

Tempest Weather Display

ESP32
Photo of Tempest Weather Display
Generated with AI

David Mahoney

Published September 28, 2026

The Tempest Weather Display is an ESP32-powered e-paper weather station that fetches current conditions and forecasts from a weather API and displays them on a Elecrow e-paper panel. The display updates automatically and includes temperature, humidity, wind speed, precipitation, and multi-day forecasts with customizable viewing options.

This guide provides a complete parts list, wiring diagram, and step-by-step assembly instructions. You'll configure the weather settings through a temporary WiFi network, load the provided firmware onto the ESP32, and learn how to navigate between weather pages using the built-in controls.

Wiring diagram

Assemble it in 4 steps

1. Power the weather display

Place the CrowPanel where you can read the screen, then connect its USB port to USB power. The picture stays visible when power is removed, but the board needs power to download fresh weather.

  • Leave the USB cable connected while you set it up.
  • Use the board’s USB connector only and do not force the plug.

2. Join the temporary setup network

On your phone, tablet, or computer, open the Wi-Fi list and join Tempest-Setup. Enter weather-setup as the password, then open http://192.168.4.1 in a browser.

  • Stay connected even if your device says this network has no internet. It is a direct connection to the display.
  • Use a 2.4 GHz home Wi-Fi network when saving your settings. A 5 GHz-only network cannot be used by this display.

3. Save the weather settings

Enter your home Wi-Fi name and password, station number 137372, the read-only Tempest token, and your local time zone, then press Save. The display connects and fetches weather without needing a manual restart.

  • The screen shows its current weather page after the first download.
  • Open http://tempest-weather-579.local/weather on your home network to view the latest data in a browser. If that name does not open, use the board’s local network address from your router.
  • Keep the Tempest token private. It is saved on the board but is never shown on the setup page or printed in device messages.

4. Change weather pages

Press MENU or EXIT, or press or turn the built-in rotary control, to move through the three screen pages. The first page has large current weather text. The second page shows forecast days 1 through 5. The third page shows days 6 through 10.

  • A quick tap counts once, so hold the control only if you want to change pages again after a short pause.
  • Do not press the BOOT or RESET buttons while the display is downloading weather; they restart the board.

Deploy the firmware

// Select exactly one supported Elecrow e-paper unit before deployment.
// 0 = CrowPanel 5.79-inch DIS08792E (active default)
// 1 = earlier CrowPanel 2.13-inch, 250 x 122
#ifndef TEMPEST_PANEL_2IN13
#define TEMPEST_PANEL_2IN13 0
#endif

#include <Arduino.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <WebServer.h>
#include <Preferences.h>
#include <WiFiClientSecure.h>
#include "PanelDisplay.h"

struct Weather { bool valid=false; float temperature=NAN,humidity=NAN,wind=NAN,windDirection=NAN,rain=NAN,pressure=NAN; long long observedAt=0; String condition="Unavailable",updated="UPDATED: TIME UNAVAILABLE"; } weather;
struct ForecastDay { bool valid=false; long long start=0,sunrise=0,sunset=0; float high=NAN,low=NAN,precip=NAN; String condition="Unavailable",precipType=""; };

// Forward declarations

// Forward declarations
void filledDisc(int x,int y,int radius);
void filledEllipse(int x,int y,int rx,int ry);

String savedTimeFormat();

ForecastDay forecast[10]; uint8_t forecastCount=0;
uint8_t displayPage=0; uint32_t lastButtonAt=0;
#if !TEMPEST_PANEL_2IN13
// Built-in 5.79-inch controls are active-low: EXIT, MENU, rotary down, press, up.
constexpr uint8_t NAV_PINS[]={1,2,4,5,6};
bool navWasDown[sizeof(NAV_PINS)]={false};
#endif

// Forward declarations

// Forward declarations

// Forward declarations
bool jsonScalar(const String&o,const char*n,String&v);

bool jsonString(const String&o,const char*n,String&v);
bool conditionsObject(const String&b,String&o);
bool captureWeatherObjects(WiFiClientSecure& client,String& current,String& daily);

String html(const String&s);
String webIcon(const String& condition);
String webDay(long long seconds, bool today);
String windCardinal(float degrees);
String stationId();
String savedZone();
bool validZone(const String&z);
void pixel(int x,int y);
void rect(int x,int y,int w,int h);
void box(int x,int y,int w,int h,int t);
void text(int x,int y,const char*s,int scale);
void line(int x0,int y0,int x1,int y1);
void windCompass(int x,int y,float degrees);
void sunSymbol(int x,int y);
void moonAndStars(int x,int y);
void forecastIcon(int x,int y,const String&condition);
String centralClock(long long seconds);
void clearCanvas();
bool refreshPanel();
void status(const char*a,const char*b,const char*c);
void icon(int x,int y,const String&condition);
void drawWeather();
void pollDisplayButtons();
void parseForecast(const String& daily);
bool jsonNumber(const String&o,const char*n,float&v);
bool connectWiFi();

// Each enclosure has its own fixed internal display wiring in Panel213.cpp or
// Panel579.cpp. No external display wires are used.
constexpr uint16_t PANEL_W=PANEL_WIDTH, PANEL_H=PANEL_HEIGHT;
constexpr uint32_t WEATHER_REFRESH_MS=600000UL;
constexpr char DEFAULT_STATION[]="137372";
Preferences prefs; WebServer server(80); bool serverStarted=false,setupMode=false,panelOk=false; uint32_t lastFetch=0; String lastSummary;

String html(const String&s){String o;for(char c:s){if(c=='&')o+="&amp;";else if(c=='<')o+="&lt;";else if(c=='>')o+="&gt;";else if(c=='\"')o+="&quot;";else o+=c;}return o;}
String stationId(){prefs.begin("weather",true);String id=prefs.getString("station",DEFAULT_STATION);prefs.end();if(!id.length()||id.length()>12)return DEFAULT_STATION;for(char c:id)if(c<'0'||c>'9')return DEFAULT_STATION;return id;}
String savedZone(){prefs.begin("weather",true);String z=prefs.getString("tz","CST6CDT,M3.2.0,M11.1.0");prefs.end();return z;}
// Migrate existing units once: older saved settings have no clock entry.  Write
// the 12-hour default before reading it so Preferences does not repeatedly log
// a harmless NOT_FOUND error while rendering the page or display.
String savedTimeFormat(){
  prefs.begin("weather",false);
  String f;
  if(prefs.isKey("clock12")) f=prefs.getString("clock12");
  else { prefs.putString("clock12","12"); f="12"; }
  prefs.end();
  return f=="24"?"24":"12";
}
bool validZone(const String&z){return z=="EST5EDT,M3.2.0,M11.1.0"||z=="CST6CDT,M3.2.0,M11.1.0"||z=="MST7MDT,M3.2.0,M11.1.0"||z=="PST8PDT,M3.2.0,M11.1.0"||z=="AKST9AKDT,M3.2.0,M11.1.0"||z=="HST10";}
void pixel(int x,int y){if(x<0||y<0||x>=PANEL_W||y>=PANEL_H)return;ImageBW[(size_t)y*PANEL_ROW_BYTES+x/8]&=~(0x80>>(x&7));}
void rect(int x,int y,int w,int h){for(int yy=y;yy<y+h;++yy)for(int xx=x;xx<x+w;++xx)pixel(xx,yy);}
void box(int x,int y,int w,int h,int t=3){rect(x,y,w,t);rect(x,y+h-t,w,t);rect(x,y,t,h);rect(x+w-t,y,t,h);}
void line(int x0,int y0,int x1,int y1){int dx=abs(x1-x0),sx=x0<x1?1:-1,dy=-abs(y1-y0),sy=y0<y1?1:-1,e=dx+dy;for(;;){pixel(x0,y0);if(x0==x1&&y0==y1)break;int e2=2*e;if(e2>=dy){e+=dy;x0+=sx;}if(e2<=dx){e+=dx;y0+=sy;}}}
// A large compass with labels outside its rim is easier to read on the e-paper panel.
void windCompass(int x,int y,float degrees){const int cx=x+31,cy=y+42,r=25;for(int a=0;a<360;a+=5){float q=a*PI/180.0f;rect(cx+(int)(cosf(q)*r)-1,cy+(int)(sinf(q)*r)-1,3,3);}text(cx-3,cy-r-11,"N",1);text(cx+r+5,cy-3,"E",1);text(cx-3,cy+r+5,"S",1);text(cx-r-10,cy-3,"W",1);if(!isfinite(degrees))return;float q=(degrees-90.0f)*PI/180.0f;int tipX=cx+(int)(cosf(q)*(r-3)),tipY=cy+(int)(sinf(q)*(r-3));float left=q+2.48f,right=q-2.48f;int leftX=cx+(int)(cosf(left)*10),leftY=cy+(int)(sinf(left)*10),rightX=cx+(int)(cosf(right)*10),rightY=cy+(int)(sinf(right)*10);for(int yy=-2;yy<=2;++yy)line(cx,cy+yy,tipX,tipY+yy);for(int step=0;step<=10;++step){float t=step/10.0f;int ax=tipX+(int)((leftX-tipX)*t),ay=tipY+(int)((leftY-tipY)*t),bx=tipX+(int)((rightX-tipX)*t),by=tipY+(int)((rightY-tipY)*t);line(ax,ay,bx,by);}}
void sunSymbol(int x,int y){for(int dx=-9;dx<=9;++dx)for(int dy=-9;dy<=9;++dy)if(dx*dx+dy*dy<42)pixel(x+dx,y+dy);for(int a=0;a<360;a+=45){float q=a*PI/180.0f;line(x+(int)(cosf(q)*12),y+(int)(sinf(q)*12),x+(int)(cosf(q)*18),y+(int)(sinf(q)*18));}}
// A solid dark moon avoids a white cutout shape appearing over the moon.
void moonAndStars(int x,int y){
  filledDisc(x,y,29);
  // Tiny white crater highlights are kept well inside the moon, not as a second shape.
  for(int dx=-2;dx<=2;++dx)for(int dy=-2;dy<=2;++dy){
    if(dx*dx+dy*dy<=4){
      int px=x-9+dx,py=y-10+dy;
      if(px>=0&&py>=0&&px<PANEL_W&&py<PANEL_H)ImageBW[(size_t)py*PANEL_ROW_BYTES+px/8]|=(0x80>>(px&7));
    }
  }
  for(int sx=-45;sx<=42;sx+=42)for(int sy=-31;sy<=-6;sy+=17){line(x+sx-3,y+sy,x+sx+3,y+sy);line(x+sx,y+sy-3,x+sx,y+sy+3);}
}
// Filled round shapes make the monochrome cloud resemble the rounded SVG cloud
// on the web page instead of using a square block beneath the cloud bumps.
void filledDisc(int x,int y,int radius){for(int dx=-radius;dx<=radius;++dx)for(int dy=-radius;dy<=radius;++dy)if(dx*dx+dy*dy<=radius*radius)pixel(x+dx,y+dy);}
void filledEllipse(int x,int y,int rx,int ry){long rx2=(long)rx*rx,ry2=(long)ry*ry,limit=rx2*ry2;for(int dx=-rx;dx<=rx;++dx)for(int dy=-ry;dy<=ry;++dy)if((long)dx*dx*ry2+(long)dy*dy*rx2<=limit)pixel(x+dx,y+dy);}
// Compact forecast symbols: clear, cloud, fog, snow, rain, thunder, sleet,
// ice/frost, hail, and tornado all remain readable on the monochrome panel.
void forecastIcon(int x,int y,const String&condition){
  String c=condition;c.toLowerCase();
  bool tornado=c.indexOf("tornado")>=0, hail=c.indexOf("hail")>=0;
  bool ice=c.indexOf("ice")>=0||c.indexOf("frost")>=0;
  bool sleet=c.indexOf("sleet")>=0||c.indexOf("wintry")>=0;
  bool snow=c.indexOf("snow")>=0, storm=c.indexOf("thunder")>=0||c.indexOf("storm")>=0;
  bool rain=storm||c.indexOf("rain")>=0||c.indexOf("shower")>=0||c.indexOf("drizzle")>=0;
  bool fog=c.indexOf("fog")>=0||c.indexOf("mist")>=0;
  bool cloud=rain||snow||sleet||ice||hail||fog||c.indexOf("cloud")>=0||c.indexOf("overcast")>=0;
  if(tornado){for(int yy=-14;yy<14;yy+=6)line(x-18+abs(yy)/2,y+yy,x+18-abs(yy)/2,y+yy);line(x,y+14,x+5,y+22);return;}
  if(!cloud){sunSymbol(x,y);return;}
  // Three round lobes and an oval underside form a soft cloud silhouette.
  filledEllipse(x,y+8,24,10);filledDisc(x-14,y+5,11);filledDisc(x,y-2,16);filledDisc(x+16,y+7,10);
  if(fog){line(x-20,y+14,x+20,y+14);line(x-15,y+19,x+15,y+19);return;}
  if(storm){line(x+2,y+11,x-5,y+20);line(x+1,y+20,x-4,y+27);}
  if(rain)for(int q=-12;q<=12;q+=12)line(x+q,y+14,x+q-3,y+21);
  if(snow||sleet||ice||hail)for(int q=-12;q<=12;q+=12){line(x+q-3,y+17,x+q+3,y+17);line(x+q,y+14,x+q,y+20);line(x+q-2,y+15,x+q+2,y+19);line(x+q-2,y+19,x+q+2,y+15);}
  if(hail)for(int q=-12;q<=12;q+=12){rect(x+q-2,y+16,5,5);}
}
const uint8_t*glyph(char c){static const uint8_t b[5]={};static const uint8_t n[10][5]={{0x3e,0x51,0x49,0x45,0x3e},{0,0x42,0x7f,0x40,0},{0x42,0x61,0x51,0x49,0x46},{0x21,0x41,0x45,0x4b,0x31},{0x18,0x14,0x12,0x7f,0x10},{0x27,0x45,0x45,0x45,0x39},{0x3c,0x4a,0x49,0x49,0x30},{1,0x71,9,5,3},{0x36,0x49,0x49,0x49,0x36},{6,0x49,0x49,0x29,0x1e}};static const uint8_t u[26][5]={{0x7e,0x11,0x11,0x11,0x7e},{0x7f,0x49,0x49,0x49,0x36},{0x3e,0x41,0x41,0x41,0x22},{0x7f,0x41,0x41,0x22,0x1c},{0x7f,0x49,0x49,0x49,0x41},{0x7f,9,9,9,1},{0x3e,0x41,0x49,0x49,0x7a},{0x7f,8,8,8,0x7f},{0,0x41,0x7f,0x41,0},{0x20,0x40,0x41,0x3f,1},{0x7f,8,0x14,0x22,0x41},{0x7f,0x40,0x40,0x40,0x40},{0x7f,2,12,2,0x7f},{0x7f,4,8,0x10,0x7f},{0x3e,0x41,0x41,0x41,0x3e},{0x7f,9,9,9,6},{0x3e,0x41,0x51,0x21,0x5e},{0x7f,9,0x19,0x29,0x46},{0x46,0x49,0x49,0x49,0x31},{1,1,0x7f,1,1},{0x3f,0x40,0x40,0x40,0x3f},{0x1f,0x20,0x40,0x20,0x1f},{0x7f,0x20,0x18,0x20,0x7f},{0x63,0x14,8,0x14,0x63},{7,8,0x70,8,7},{0x61,0x51,0x49,0x45,0x43}};static const uint8_t colon[5]={0,0x36,0x36,0,0},dot[5]={0,0x60,0x60,0,0},dash[5]={8,8,8,8,8},slash[5]={0x20,0x10,8,4,2},pct[5]={0x63,0x13,8,0x64,0x63};if(c>='a'&&c<='z')c-=32;if(c>='A'&&c<='Z')return u[c-'A'];if(c>='0'&&c<='9')return n[c-'0'];if(c==':')return colon;if(c=='.')return dot;if(c=='-')return dash;if(c=='/')return slash;if(c=='%')return pct;return b;}
void text(int x,int y,const char*s,int scale=1){while(*s){const uint8_t*g=glyph(*s++);for(int a=0;a<5;++a)for(int b=0;b<7;++b)if(g[a]&(1<<b))rect(x+a*scale,y+b*scale,scale,scale);x+=6*scale;}}
void clearCanvas(){memset(ImageBW,0xFF,PANEL_FRAME_BYTES);}
bool refreshPanel(){
  if(!panelPresent(ImageBW)){
    Serial.println("Tempest display-update failed; panel did not become ready");
    return false;
  }
  Serial.println("Tempest display refresh complete");
  return true;
}
void status(const char*a,const char*b,const char*c){if(!panelOk){Serial.println("Tempest display status skipped; panel is not ready");return;}clearCanvas();
#if TEMPEST_PANEL_2IN13
box(2,2,246,118,1);text(8,10,"TEMPEST",2);text(8,42,a);text(8,60,b);text(8,78,c);
#else
box(12,12,768,248);text(36,35,"TEMPEST WEATHER",4);box(30,85,732,115);text(55,110,a,3);text(55,145,b,3);text(55,180,c,3);
#endif
refreshPanel();}
// Large current-condition drawing. Matching is deliberately based on words in
// Tempest's condition text so new phrases still get a sensible safe graphic.
void icon(int x,int y,const String&condition){
  String c=condition;c.toLowerCase();bool tornado=c.indexOf("tornado")>=0,hail=c.indexOf("hail")>=0;
  bool ice=c.indexOf("ice")>=0||c.indexOf("frost")>=0,sleet=c.indexOf("sleet")>=0||c.indexOf("wintry")>=0,snow=c.indexOf("snow")>=0;
  bool storm=c.indexOf("thunder")>=0||c.indexOf("storm")>=0,rain=storm||c.indexOf("rain")>=0||c.indexOf("shower")>=0||c.indexOf("drizzle")>=0;
  bool fog=c.indexOf("fog")>=0||c.indexOf("mist")>=0,cloud=rain||snow||sleet||ice||hail||fog||c.indexOf("cloud")>=0||c.indexOf("overcast")>=0;
  if(tornado){for(int yy=-38;yy<38;yy+=12)line(x-48+abs(yy)/2,y+yy,x+48-abs(yy)/2,y+yy);line(x,y+38,x+10,y+55);return;}
  if(!cloud){bool afterSunset=forecastCount&&forecast[0].sunset>0&&weather.observedAt>=forecast[0].sunset;if(afterSunset){moonAndStars(x,y);return;}for(int dx=-28;dx<=28;++dx)for(int dy=-28;dy<=28;++dy){int d=dx*dx+dy*dy;if(d>420&&d<760)pixel(x+dx,y+dy);}rect(x-4,y-52,8,18);rect(x-4,y+35,8,18);rect(x-52,y-4,18,8);rect(x+35,y-4,18,8);return;}
  // A rounded lower edge plus three overlapping round lobes avoids the old
  // rectangular cloud base and matches the softer cloud used on the web page.
  filledEllipse(x,y+18,60,20);filledDisc(x-34,y+10,25);filledDisc(x,y-8,36);filledDisc(x+37,y+12,24);
  if(fog){line(x-45,y+38,x+45,y+38);line(x-35,y+50,x+35,y+50);return;}
  if(storm){line(x+8,y+26,x-8,y+48);line(x-8,y+48,x+3,y+48);line(x+3,y+48,x-10,y+65);}
  if(rain)for(int q=-30;q<=30;q+=30){line(x+q,y+42,x+q-7,y+59);}
  if(snow||sleet||ice||hail)for(int q=-30;q<=30;q+=30){line(x+q-7,y+51,x+q+7,y+51);line(x+q,y+44,x+q,y+58);line(x+q-5,y+46,x+q+5,y+56);line(x+q-5,y+56,x+q+5,y+46);}
  if(hail)for(int q=-30;q<=30;q+=30)rect(x+q-4,y+48,9,9);
}
void drawWeather(){if(!panelOk){Serial.println("Tempest weather drawing skipped; panel is not ready");return;}clearCanvas();char v[40];
#if TEMPEST_PANEL_2IN13
box(2,2,246,118,1);text(7,7,"TEMPEST WEATHER");snprintf(v,sizeof(v),"%.0f F",weather.temperature);text(8,25,v,4);snprintf(v,sizeof(v),"HUM %.0f%%",weather.humidity);text(112,26,v,2);snprintf(v,sizeof(v),"WIND %.1f MPH",weather.wind);text(112,45,v);snprintf(v,sizeof(v),"RAIN %.2f IN",weather.rain);text(8,58,v);snprintf(v,sizeof(v),"PRESS %.2f INHG",weather.pressure);text(8,72,v);text(8,88,weather.condition.c_str());text(8,104,weather.updated.c_str());
#else
if(displayPage==0){
  box(8,8,776,256);text(24,18,"TEMPEST WEATHER",3);text(24,45,weather.updated.c_str(),2);
  // Daily rain uses the open header area. The small page marker moves to the
  // lower-right corner, leaving the rain total in its own readable box.
  box(586,12,182,46);text(598,19,"RAIN TODAY",1);snprintf(v,sizeof(v),"%.2f IN",weather.rain);text(620,34,v,2);
  // The shared NOW box gives temperature, pressure, units, graphic, and
  // condition text their own lanes so they cannot print over one another.
  box(20,70,348,166);text(38,84,"NOW",3);snprintf(v,sizeof(v),"%.0f F",weather.temperature);text(38,119,v,4);
  text(40,166,"PRESSURE",1);snprintf(v,sizeof(v),"%.2f",weather.pressure);text(40,180,v,3);text(40,210,"INHG",2);
  String condition=weather.condition;condition.toUpperCase();if(condition.indexOf("THUNDER")>=0||condition.indexOf("STORM")>=0)condition="T-STORMS";if(condition.length()>17)condition=condition.substring(0,17);
  icon(274,126,weather.condition);text(182,204,condition.c_str(),2);
  box(386,70,178,64);text(400,82,"HUM",2);snprintf(v,sizeof(v),"%.0f%%",weather.humidity);text(468,86,v,4);
  box(580,70,186,64);text(594,82,"SUNRISE",2);sunSymbol(610,111);String rise=forecastCount&&forecast[0].sunrise?centralClock(forecast[0].sunrise):"--:--";text(634,100,rise.c_str(),3);
  box(386,146,178,90);windCompass(394,150,weather.windDirection);snprintf(v,sizeof(v),"%.0f",weather.wind);int digits=strlen(v);text(546-(digits*12),162,v,4);text(508,202,"MPH",2);
  box(580,146,186,90);text(594,158,"SUNSET",2);sunSymbol(610,199);String set=forecastCount&&forecast[0].sunset?centralClock(forecast[0].sunset):"--:--";text(634,188,set.c_str(),3);
  text(24,244,"PRESS MENU OR DIAL FOR FORECAST",2);text(700,246,"PAGE 1/3",1);
}else{
  uint8_t first=displayPage==1?0:5; box(8,8,776,256);snprintf(v,sizeof(v),"FORECAST %u-%u",first+1,first+5);text(22,18,v,3);text(640,22,displayPage==1?"PAGE 2/3":"PAGE 3/3",2);
  // Cards reserve separate lanes: day, icon, condition, HI, LO, and rain.
  for(uint8_t i=0;i<5;++i){uint8_t n=first+i;int x=16+i*154;box(x,58,144,177);if(n>=forecastCount||!forecast[n].valid){text(x+20,126,"NO DATA",2);continue;}String day=n==0?"TODAY":String("DAY ")+String(n+1);text(x+12,70,day.c_str(),2);forecastIcon(x+72,103,forecast[n].condition);String label=forecast[n].condition;label.toUpperCase();if(label.indexOf("THUNDER")>=0||label.indexOf("STORM")>=0)label="T-STORMS";if(label.length()>17)label=label.substring(0,17);text(x+10,132,label.c_str(),1);snprintf(v,sizeof(v),"HI %.0f",forecast[n].high);text(x+12,151,v,2);snprintf(v,sizeof(v),"LO %.0f",forecast[n].low);text(x+12,174,v,2);if(isfinite(forecast[n].precip))snprintf(v,sizeof(v),"RAIN %.0f%%",forecast[n].precip);else snprintf(v,sizeof(v),"RAIN --");text(x+12,204,v,2);}
  text(24,244,"PRESS MENU OR DIAL FOR NEXT PAGE",2);
}
#endif
refreshPanel();}
bool jsonNumber(const String&o,const char*n,float&v){String k=String("\"")+n+"\"";int at=o.indexOf(k);if(at<0)return false;at=o.indexOf(':',at+k.length());if(at<0)return false;const char* p=o.c_str()+at+1;while(*p==' '||*p=='\t'||*p=='\r'||*p=='\n')++p;char*e=nullptr;v=strtof(p,&e);return e!=p&&isfinite(v);}
bool jsonString(const String&o,const char*n,String&v){String k=String("\"")+n+"\"";int at=o.indexOf(k);if(at<0)return false;at=o.indexOf(':',at+k.length());if(at<0)return false;at++;while(at<(int)o.length()&&(o[at]==' '||o[at]=='\t'||o[at]=='\r'||o[at]=='\n'))++at;if(at>=(int)o.length()||o[at]!='\"')return false;int end=o.indexOf('\"',at+1);if(end<0)return false;v=o.substring(at+1,end);return true;}
bool jsonScalar(const String&o,const char*n,String&v){String k=String("\"")+n+"\"";int at=o.indexOf(k);if(at<0)return false;at=o.indexOf(':',at+k.length());if(at<0)return false;at++;while(at<(int)o.length()&&(o[at]==' '||o[at]=='\t'||o[at]=='\r'||o[at]=='\n'))++at;int end=at;if(at<(int)o.length()&&o[at]=='\"'){++at;end=o.indexOf('\"',at);if(end<0)return false;v=o.substring(at,end);return true;}while(end<(int)o.length()&&((o[end]>='0'&&o[end]<='9')||o[end]=='.'||o[end]=='-'||o[end]=='+'))++end;if(end==at)return false;v=o.substring(at,end);return true;}
// Stream the live object and the compact daily array only. Hourly forecast data is
// never retained. A malformed or unusually large daily section must not hide
// otherwise valid current weather from the screen or the /weather page.
bool captureWeatherObjects(WiFiClientSecure& client,String& current,String& daily){
  const char currentKey[]="\"current_conditions\"",dailyKey[]="\"daily\""; size_t cm=0,dm=0,bytes=0;
  bool wantCurrent=false,wantDaily=false,capturing=false,inQuote=false,escaped=false,dropDaily=false; char close=0; int depth=0; String* out=nullptr; uint32_t deadline=millis()+20000;
  current.reserve(2048); daily.reserve(12288);
  while((client.connected()||client.available())&&millis()<deadline){
    if(!client.available()){delay(1);continue;} char c=(char)client.read();
    if((++bytes&1023U)==0)delay(1); // keep Wi-Fi and the watchdog serviced during a long reply
    if(capturing){
      size_t limit=(out==&current)?8192:16384;
      if(out->length()>=limit){
        if(out==&current){Serial.println("Tempest stage response parsing: current_conditions section too large");return false;}
        Serial.println("Tempest stage response parsing: daily forecast too large; current weather will still update");
        daily=""; dropDaily=true;
      }
      if(!dropDaily)*out+=c;
      if(inQuote){if(escaped)escaped=false;else if(c=='\\')escaped=true;else if(c=='\"')inQuote=false;}
      else if(c=='\"')inQuote=true;else if(c==(close=='}'?'{':'['))++depth;else if(c==close&&!--depth){capturing=false;out=nullptr;dropDaily=false;} continue;
    }
    if(wantCurrent&&c=='{'){current="{";out=&current;capturing=true;close='}';depth=1;wantCurrent=false;continue;}
    if(wantDaily&&c=='['){daily="[";out=&daily;capturing=true;close=']';depth=1;wantDaily=false;continue;}
    if(c==currentKey[cm]){if(++cm==sizeof(currentKey)-1){wantCurrent=true;cm=0;}}else cm=(c==currentKey[0])?1:0;
    if(c==dailyKey[dm]){if(++dm==sizeof(dailyKey)-1){wantDaily=true;dm=0;}}else dm=(c==dailyKey[0])?1:0;
  }
  if(!current.length()){Serial.println("Tempest stage response parsing: current_conditions missing");return false;}
  if(!daily.length())Serial.println("Tempest stage response parsing: daily forecast unavailable");
  return true;
}
void parseForecast(const String& daily){forecastCount=0;if(!daily.length())return;bool inQuote=false,escaped=false;int depth=0,start=-1;for(int i=0;i<(int)daily.length()&&forecastCount<10;++i){char c=daily[i];if(c=='\"'&&(i==0||daily[i-1]!='\\'))inQuote=!inQuote;if(inQuote)continue;if(c=='{'){if(depth++==0)start=i;}else if(c=='}'&&--depth==0&&start>=0){String o=daily.substring(start,i+1);ForecastDay &d=forecast[forecastCount];d=ForecastDay();float stamp;bool a=jsonNumber(o,"day_start_local",stamp);if(!a)a=jsonNumber(o,"time",stamp);d.start=(long long)stamp;d.valid=a&&jsonNumber(o,"air_temp_high",d.high)&&jsonNumber(o,"air_temp_low",d.low);float sun;if(jsonNumber(o,"sunrise",sun))d.sunrise=(long long)sun;if(jsonNumber(o,"sunset",sun))d.sunset=(long long)sun;jsonNumber(o,"precip_probability",d.precip);jsonString(o,"conditions",d.condition);jsonString(o,"precip_type",d.precipType);if(d.valid)++forecastCount;start=-1;}}
}
void pollDisplayButtons(){
#if !TEMPEST_PANEL_2IN13
  for(size_t i=0;i<sizeof(NAV_PINS);++i){bool down=digitalRead(NAV_PINS[i])==LOW;if(down&&!navWasDown[i]&&millis()-lastButtonAt>120){displayPage=(displayPage+1)%3;lastButtonAt=millis();Serial.printf("Tempest display page %u selected\n",displayPage+1);drawWeather();}navWasDown[i]=down;}
#endif
}
String trimmed(String s){s.trim();return s;}
String urlEncode(const String&s){const char hex[]="0123456789ABCDEF";String o;for(size_t i=0;i<s.length();++i){uint8_t c=(uint8_t)s[i];if((c>='a'&&c<='z')||(c>='A'&&c<='Z')||(c>='0'&&c<='9')||c=='-'||c=='_'||c=='.'||c=='~')o+=(char)c;else{o+='%';o+=hex[c>>4];o+=hex[c&15];}}return o;}
// Converts a Gregorian calendar date to whole days since 1970-01-01 without
// timegm(), which is not supplied by this ESP32 Arduino target.
int64_t daysSinceUnixEpoch(int year,unsigned month,unsigned day){
  year-=month<=2;
  const int era=(year>=0?year:year-399)/400;
  const unsigned yoe=(unsigned)(year-era*400);
  const unsigned doy=(153*(month+(month>2?-3:9))+2)/5+day-1;
  const unsigned doe=yoe*365+yoe/4-yoe/100+doy;
  return (int64_t)era*146097+(int64_t)doe-719468;
}
String centralTimestamp(const String&raw){String e=trimmed(raw);long long seconds=0;bool digits=e.length()>0;for(char c:e)if(c<'0'||c>'9'){digits=false;break;}if(digits){seconds=strtoll(e.c_str(),nullptr,10);if(e.length()>=13)seconds/=1000;}else{int y,mo,d,hh,mm,ss=0,oh=0,om=0;char sign=0;int fields=sscanf(e.c_str(),"%d-%d-%dT%d:%d:%d%c%d:%d",&y,&mo,&d,&hh,&mm,&ss,&sign,&oh,&om);if(fields<5||mo<1||mo>12||d<1||d>31||hh<0||hh>23||mm<0||mm>59||ss<0||ss>60)return "UPDATED: TIME UNAVAILABLE";seconds=daysSinceUnixEpoch(y,(unsigned)mo,(unsigned)d)*86400LL+hh*3600LL+mm*60LL+ss;if(sign=='+'||sign=='-'){long off=(oh*60L+om)*60L;seconds+=(sign=='+'?-off:off);}}if(seconds<=0)return "UPDATED: TIME UNAVAILABLE";setenv("TZ","CST6CDT,M3.2.0,M11.1.0",1);tzset();time_t t=(time_t)seconds;struct tm ti;localtime_r(&t,&ti);bool twentyFour=savedTimeFormat()=="24";char o[36];strftime(o,sizeof(o),twentyFour?"UPDATED %d %b %H:%M":"UPDATED %d %b %I:%M %p",&ti);String result=o;if(!twentyFour&&result.indexOf(" 0")>=0)result.replace(" 0"," ");return result;}
String centralClock(long long seconds){if(seconds<=0)return "--:--";if(seconds>99999999999LL)seconds/=1000;setenv("TZ","CST6CDT,M3.2.0,M11.1.0",1);tzset();time_t t=(time_t)seconds;struct tm ti;if(!localtime_r(&t,&ti))return "--:--";bool twentyFour=savedTimeFormat()=="24";char o[12];strftime(o,sizeof(o),twentyFour?"%H:%M":"%I:%M %p",&ti);if(!twentyFour&&o[0]=='0')return String(o+1);return String(o);}
bool fetchWeather(){
  if(WiFi.status()!=WL_CONNECTED){Serial.println("Tempest stage Wi-Fi: disconnected");return false;}
  prefs.begin("weather",true);String token=trimmed(prefs.getString("token",""));prefs.end();
  if(!token.length()){Serial.println("Tempest stage authentication: token not saved");status("WEATHER NOT SET","TOKEN NEEDED","OPEN SETUP PAGE");return false;}
  IPAddress ip;uint32_t started=millis();int dns=WiFi.hostByName("swd.weatherflow.com",ip);
  if(dns!=1||millis()-started>6000){Serial.println("Tempest stage DNS/name lookup: failed");status("WEATHER ERROR","NETWORK DNS FAILED","CHECK INTERNET");return false;}
  Serial.println("Tempest stage DNS/name lookup: complete");WiFiClientSecure client;client.setInsecure();client.setTimeout(15);
  if(!client.connect("swd.weatherflow.com",443,15000)){Serial.println("Tempest stage secure connection: failed");status("WEATHER ERROR","SECURE LINK FAILED","TRY AGAIN LATER");return false;}
  Serial.println("Tempest stage secure connection: complete");
  // Tempest accepts the saved read-only token as a query value.  It is trimmed
  // and percent-encoded before use, and is never printed or returned by the web page.
  String path="/swd/rest/better_forecast?station_id="+stationId()+"&units_temp=f&units_wind=mph&units_pressure=inhg&units_precip=in&units_distance=mi&token="+urlEncode(token);
  client.print("GET ");client.print(path);client.print(" HTTP/1.1\r\nHost: swd.weatherflow.com\r\nUser-Agent: TempestWeatherDisplay/1.0\r\nAccept: application/json\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n");
  // WiFiClientSecure can report connected before the server has sent its
  // first byte.  Waiting here fixes a false HTTP 0 caused by reading too soon.
  uint32_t responseDeadline=millis()+15000;
  while(!client.available()&&client.connected()&&millis()<responseDeadline)delay(10);
  if(!client.available()){
    Serial.println("Tempest stage HTTP/API: no HTTP response within 15 seconds");
    client.stop();status("WEATHER ERROR","API DID NOT RESPOND","TRY AGAIN LATER");return false;
  }
  String line=client.readStringUntil('\n');line.trim();int first=line.indexOf(' '),second=first<0?-1:line.indexOf(' ',first+1);int httpCode=(first>=0&&second>first)?line.substring(first+1,second).toInt():0;
  if(httpCode==0){Serial.println("Tempest stage HTTP/API: invalid HTTP status line");client.stop();status("WEATHER ERROR","API RESPONSE INVALID","TRY AGAIN LATER");return false;}
  uint32_t headerDeadline=millis()+10000;
  while(millis()<headerDeadline){while(!client.available()&&client.connected()&&millis()<headerDeadline)delay(1);if(!client.available())break;line=client.readStringUntil('\n');if(line=="\r"||line.length()==0)break;}
  if(httpCode!=200){Serial.printf("Tempest stage HTTP/API: HTTP %d\n",httpCode);client.stop();if(httpCode==401||httpCode==403)status("WEATHER ERROR","TOKEN NOT ACCEPTED","CHECK SETUP PAGE");else if(httpCode==429)status("WEATHER ERROR","API BUSY","TRY AGAIN LATER");else status("WEATHER ERROR","API REQUEST FAILED","TRY AGAIN LATER");return false;}
  String now,daily;if(!captureWeatherObjects(client,now,daily)){client.stop();status("WEATHER ERROR","WEATHER DATA INVALID","TRY AGAIN LATER");return false;}client.stop();parseForecast(daily);
  bool t=jsonNumber(now,"air_temperature",weather.temperature),h=jsonNumber(now,"relative_humidity",weather.humidity),w=jsonNumber(now,"wind_avg",weather.wind),wd=jsonNumber(now,"wind_direction",weather.windDirection),r=jsonNumber(now,"precip_accum_local_day",weather.rain),p=jsonNumber(now,"station_pressure",weather.pressure);String epoch;bool tm=jsonScalar(now,"time",epoch);
  if(!jsonString(now,"conditions",weather.condition))weather.condition="Unavailable";
  Serial.printf("Tempest fields: temperature=%s humidity=%s wind=%s rain=%s pressure=%s conditions=%s time=%s wind_direction=%s\n",t?"present":"MISSING",h?"present":"MISSING",w?"present":"MISSING",r?"present":"MISSING",p?"present":"MISSING",weather.condition!="Unavailable"?"present":"MISSING",tm?"present":"MISSING",wd?"present":"MISSING");
  if(!(t&&h&&w&&r&&p&&tm&&wd)||weather.windDirection<0||weather.windDirection>=360||weather.pressure<20||weather.pressure>35){Serial.println("Tempest stage response parsing: incomplete or invalid values");status("WEATHER ERROR","INCOMPLETE DATA","TRY AGAIN LATER");return false;}
  weather.updated=centralTimestamp(epoch);weather.observedAt=0;bool epochDigits=epoch.length()>0;for(char c:epoch)if(c<'0'||c>'9'){epochDigits=false;break;}if(epochDigits){weather.observedAt=strtoll(epoch.c_str(),nullptr,10);if(epoch.length()>=13)weather.observedAt/=1000;}weather.valid=true;Serial.printf("Tempest values: %.1f F, %.0f%%, %.1f mph, %.2f in, %.2f inHg\n",weather.temperature,weather.humidity,weather.wind,weather.rain,weather.pressure);
  String summary=String(weather.temperature)+"|"+String(weather.humidity)+"|"+String(weather.wind)+"|"+String(weather.windDirection)+"|"+String(weather.rain)+"|"+String(weather.pressure)+"|"+weather.condition+"|"+weather.updated;for(uint8_t i=0;i<forecastCount;++i)summary+="|"+String(forecast[i].start)+"|"+String(forecast[i].high)+"|"+String(forecast[i].low)+"|"+forecast[i].condition;if(summary==lastSummary){Serial.println("Tempest display unchanged; refresh skipped");return true;}Serial.println("Tempest stage display-update: starting");drawWeather();lastSummary=summary;return true;
}
// Self-contained SVG icons avoid external image downloads and cover the full
// requested condition set. Unknown wording falls back to a clean partly-cloudy icon.
String webIcon(const String& condition){
  String c=condition;c.toLowerCase();bool tornado=c.indexOf("tornado")>=0,hail=c.indexOf("hail")>=0;
  bool ice=c.indexOf("ice")>=0||c.indexOf("frost")>=0,sleet=c.indexOf("sleet")>=0||c.indexOf("wintry")>=0,snow=c.indexOf("snow")>=0;
  bool storm=c.indexOf("storm")>=0||c.indexOf("thunder")>=0,rain=storm||c.indexOf("rain")>=0||c.indexOf("shower")>=0||c.indexOf("drizzle")>=0;
  bool fog=c.indexOf("fog")>=0||c.indexOf("mist")>=0,cloud=rain||snow||sleet||ice||hail||fog||c.indexOf("cloud")>=0||c.indexOf("overcast")>=0;
  String svg="<svg viewBox='0 0 80 64' aria-hidden='true'>";
  if(tornado)svg+="<g fill='none' stroke='#657786' stroke-width='4' stroke-linecap='round'><path d='M14 16h52M20 27h40M28 38h24M37 49h7'/></g>";
  else if(!cloud)svg+="<circle cx='40' cy='30' r='13' fill='#f6b73c'/><g stroke='#f6b73c' stroke-width='3'><path d='M40 4v10M40 46v10M14 30h10M56 30h10M21 11l7 7M52 42l7 7M59 11l-7 7M28 42l-7 7'/></g>";
  else {svg+="<path d='M17 40h45a12 12 0 0 0 0-24 18 18 0 0 0-34-3A14 14 0 0 0 17 40' fill='#a9bac8'/>";
    if(fog)svg+="<g stroke='#728a9b' stroke-width='3' stroke-linecap='round'><path d='M18 48h44M24 56h32'/></g>";
    if(rain)svg+="<g stroke='#2980c8' stroke-width='3' stroke-linecap='round'><path d='M26 46l-3 10M40 46l-3 10M54 46l-3 10'/></g>";
    if(snow||sleet||ice)svg+="<g stroke='#5ba7d8' stroke-width='2'><path d='M25 48v12m-5-6h10M39 48v12m-5-6h10M53 48v12m-5-6h10'/></g>";
    if(hail)svg+="<g fill='#58a9dc'><circle cx='26' cy='53' r='3'/><circle cx='40' cy='57' r='3'/><circle cx='54' cy='53' r='3'/></g>";
    if(storm)svg+="<path d='M43 40l-9 14h8l-5 9 14-17h-8z' fill='#e59b22'/>";
  }return svg+="</svg>";
}
String webDay(long long seconds,bool today){if(today)return "TODAY";if(seconds>99999999999LL)seconds/=1000;if(seconds<=0)return "DAY";setenv("TZ","CST6CDT,M3.2.0,M11.1.0",1);tzset();time_t t=(time_t)seconds;struct tm ti;if(!localtime_r(&t,&ti))return "DAY";char out[12];strftime(out,sizeof(out),"%a",&ti);return String(out);}
String windCardinal(float degrees){static const char* names[]={"N","NE","E","SE","S","SW","W","NW"};if(!isfinite(degrees)||degrees<0||degrees>=360)return "--";return String(names[((int)(degrees+22.5f)%360)/45]);}
void weatherPage(){
  String page="<!doctype html><html><head><meta name=viewport content='width=device-width,initial-scale=1'><meta http-equiv=refresh content=300><title>Tempest Weather</title><style>body{font-family:Arial,sans-serif;background:linear-gradient(#dff3ff,#f7fbff);color:#16304a;margin:0}main{max-width:1050px;margin:auto;padding:18px}h1{text-align:center;margin:0}.sub{text-align:center;color:#4d6478}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;max-width:980px;margin:0 auto}.card,.day{box-sizing:border-box;background:#fff;border:1px solid #d5e2ec;border-radius:16px;padding:8px 18px 18px;box-shadow:0 3px 10px #8ca4b544;overflow:hidden}.card{text-align:center;display:flex;min-height:250px;flex-direction:column;align-items:center;justify-content:flex-start;gap:8px}.card>b{display:block;height:22px;line-height:22px;margin:0;text-align:center;font-size:1.15em}.v{font-size:1.35em;font-weight:bold;line-height:1.18;max-width:100%}.v svg{display:block;width:86px;height:64px;margin:2px auto 0}.metric{display:flex;align-items:center;justify-content:center;gap:10px;max-width:100%}.metric b{white-space:nowrap}.temperaturemetric,.pressuremetric{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;width:100%}.temperaturemetric b,.pressuremetric b{white-space:nowrap}.thermo{display:block;flex:none;width:78px;height:112px}.conditionscard .v{display:flex;flex:1;min-height:0;width:100%;flex-direction:column;align-items:center;justify-content:center}.conditionscard .v svg{width:154px;height:122px;margin:0 auto 7px}.conditiontext{max-width:100%;font-size:1.08em;line-height:1.15;text-align:center}.humiditycard{padding-left:0;padding-right:0}.humiditycard>b{padding:0 18px}.humiditymetric{display:flex;flex-direction:column;align-items:center;width:100%;gap:8px}.humiditymetric b{font-size:1.35em}.humidityrange{display:block;width:100%;height:166px}.humidityrange .marker{stroke:#243f55;stroke-width:5}.humidityrange .markerDot{fill:#fff;stroke:#243f55;stroke-width:5}.humiditystatus{font:700 20px Arial;line-height:1;text-align:center}.humiditystatus.dry{color:#c43c32}.humiditystatus.comfort{color:#238044}.humiditystatus.wet{color:#1976b8}.compass{display:block;flex:none;width:116px;height:116px}.compass svg{display:block;width:116px;height:116px}.compass .needle{transform-origin:50px 50px}.compass text{font:700 11px Arial;fill:#294d68;text-anchor:middle;dominant-baseline:middle}.rainmetric{display:flex;align-items:center;justify-content:center;min-height:142px}.gauge{position:relative;overflow:hidden;width:64px;height:126px;border:4px solid #1875ae;border-radius:32px 32px 18px 18px;background:#edf9ff;box-shadow:inset 0 0 0 4px #fff}.gauge:before{content:'';position:absolute;left:0;right:0;bottom:0;height:var(--fill);background:linear-gradient(to top,#218fce,#75cff0);border-radius:0 0 14px 14px}.gauge:after{content:'';position:absolute;left:11px;right:11px;bottom:calc(var(--fill) - 3px);height:6px;border-radius:50%;background:#c9f0ff;opacity:.9}.dial{display:block;width:160px;height:90px}.dial svg{display:block;width:160px;height:90px}.dial .needle{transform-origin:80px 80px}.forecast{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));gap:12px}.day{text-align:center;min-height:185px;display:flex;flex-direction:column}.day svg{display:block;width:80px;height:62px;margin:5px auto}.cond{min-height:38px;font-weight:bold}.hi{color:#c43c32;font-weight:bold}.lo{color:#2774b8;font-weight:bold}.sunblock{margin-top:auto}.sun{color:#d47b16;font-weight:bold}.rain{color:#1976b8;font-weight:bold}a{color:#176ca5}small{color:#52697c}@media(max-width:700px){main{padding:12px}.grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.card{min-height:205px;padding:8px 12px 12px}.metric{gap:8px}.compass{transform:scale(.78)}.rainmetric{transform:scale(.85)}}@media(max-width:430px){.grid{grid-template-columns:1fr}.card{min-height:220px}}</style></head><body><main><h1>Tempest Weather</h1><p class=sub>Weather data is checked every 10 minutes. This page refreshes every 5 minutes.</p>";
  if(!weather.valid){page+="<div class=card>Weather data is not available yet. Connect Wi-Fi and save a Tempest token on the setup page.</div>";}
  else {char x[40];page+="<p><a href='https://tempestwx.com/station/"+stationId()+"/grid'>Station "+stationId()+"</a> &middot; "+html(weather.updated)+" Central Time</p><div class=grid>";
    const char*l[]={"Temperature","Humidity","Wind","Daily rain","Station pressure","Conditions"};String v[6];float tempFill=constrain((weather.temperature+20.0f)*100.0f/130.0f,0.0f,100.0f);String tempColor=weather.temperature>80.0f?"#dc4038":"#287fc1";String ice=weather.temperature<32.0f?"<g fill='#dff6ff' stroke='#4699c9' stroke-width='1.5'><rect x='54' y='43' width='12' height='12' rx='2'/><rect x='65' y='49' width='12' height='12' rx='2'/><path d='M56 48l8-4m4 11l7-4'/></g>":"";snprintf(x,sizeof(x),"%.1f &deg;F",weather.temperature);v[0]=String("<div class=temperaturemetric><svg class=thermo viewBox='0 0 92 116' role='img' aria-label='Temperature gauge'><path d='M37 12a9 9 0 0 1 18 0v55a23 23 0 1 1-18 0z' fill='#edf5fa' stroke='#315d78' stroke-width='4'/><path d='M43 18h6v55a17 17 0 1 1-6 0z' fill='")+tempColor+"'/><rect x='43' y='"+String(73.0f-tempFill*0.55f,1)+"' width='6' height='"+String(tempFill*0.55f+8.0f,1)+"' fill='"+tempColor+"'/><circle cx='46' cy='90' r='11' fill='"+tempColor+"'/>"+ice+"</svg><b>"+x+"</b></div>";float humidityFill=constrain(weather.humidity,0.0f,100.0f);float humidityMarker=96.0f-humidityFill*0.84f;snprintf(x,sizeof(x),"%.0f%%",weather.humidity);
    float humidityMarkerX=8.0f+humidityFill*3.04f;
    String humidityClass=weather.humidity<30.0f?"dry":(weather.humidity<=50.0f?"comfort":"wet");
    String humidityText=weather.humidity<30.0f?"DRY":(weather.humidity<=50.0f?"COMFORTABLE":"WET");
    String humiditySvg="<div class=humiditymetric><b>"+String(x)+"</b><svg class=humidityrange viewBox='0 0 320 150' role='img' aria-label='Humidity range: dry below 30 percent, comfortable from 30 to 50 percent, wet above 50 percent'><path d='M32 35H97V105H32A30 30 0 0 1 2 75V65A30 30 0 0 1 32 35Z' fill='#dc4038'/><rect x='96.5' y='35' width='63.5' height='70' fill='#38a85a'/><path d='M160 35h128a30 30 0 0 1 30 30v10a30 30 0 0 1-30 30H160z' fill='#2789c7'/><rect x='2' y='35' width='316' height='70' rx='30' fill='none' stroke='#315d78' stroke-width='3'/><path class='marker' d='M";
    humiditySvg+=String(humidityMarkerX,1);
    humiditySvg+=" 22v96'/><circle class='markerDot' cx='";
    humiditySvg+=String(humidityMarkerX,1);
    humiditySvg+="' cy='70' r='13'/></svg><div class='humiditystatus "+humidityClass+"'>"+humidityText+"</div></div>";
    v[1]=humiditySvg;float windAngle=isfinite(weather.windDirection)?weather.windDirection:0;snprintf(x,sizeof(x),"%.1f mph %s",weather.wind,windCardinal(weather.windDirection).c_str());v[2]=String("<div class=metric><span class=compass><svg viewBox='0 0 100 100' role='img' aria-label='Wind compass'><circle cx='50' cy='50' r='46' fill='#cfe3f0' stroke='#365d7b' stroke-width='4'/><circle cx='50' cy='50' r='34' fill='#fff' stroke='#b7d0df' stroke-width='2'/><text x='50' y='14'>N</text><text x='86' y='50'>E</text><text x='50' y='86'>S</text><text x='14' y='50'>W</text><g class='needle' style='transform:rotate(") + String(windAngle,0) + "deg)'><path d='M50 13 L43 54 L50 50 L57 54 Z' fill='#d8493e'/><path d='M50 87 L46 49 L50 50 L54 49 Z' fill='#365d7b'/></g><circle cx='50' cy='50' r='5' fill='#fff' stroke='#365d7b' stroke-width='2'/></svg></span><b>"+x+"</b></div>";float rainFill=weather.rain<=0.001f?0.0f:constrain(18.0f+log1pf(weather.rain)*48.0f,18.0f,92.0f);snprintf(x,sizeof(x),"%.2f in",weather.rain);v[3]=String("<div class=rainmetric><span class=gauge style='--fill:") + String(rainFill,0) + "%'></span></div><b>"+x+"</b>";float pressureAngle=constrain((weather.pressure-20.0f)*12.0f-90.0f,-90.0f,90.0f);snprintf(x,sizeof(x),"%.2f inHg",weather.pressure);v[4]=String("<div class=pressuremetric><span class=dial><svg viewBox='0 0 160 90' role='img' aria-label='Station pressure gauge'><path d='M10 80 A70 70 0 0 1 150 80 L10 80Z' fill='#e7d9ed' stroke='#40304d' stroke-width='6'/><g stroke='#40304d' stroke-width='3'><path d='M15 80l10 0'/><path d='M20 55l9 4'/><path d='M38 29l7 7'/><path d='M64 15l3 10'/><path d='M96 15l-3 10'/><path d='M122 29l-7 7'/><path d='M140 55l-9 4'/><path d='M145 80l-10 0'/></g><g class='needle' style='transform:rotate(") + String(pressureAngle,0) + "deg)'><path d='M77 80 L80 18 L83 80 Z' fill='#c52f2f' stroke='#fff' stroke-width='2'/></g><circle cx='80' cy='80' r='8' fill='#c52f2f' stroke='#fff' stroke-width='3'/></svg></span><b>"+x+"</b></div>";v[5]=String("<div class=conditiontext>")+html(weather.condition)+"</div>"+webIcon(weather.condition);
    for(int i=0;i<6;++i)page+="<div class='card"+String(i==1?" humiditycard":(i==5?" conditionscard":""))+"'><b>"+String(l[i])+"</b><div class=v>"+v[i]+"</div></div>";page+="</div><h2>10-day forecast</h2><div class=forecast>";
    for(uint8_t i=0;i<forecastCount&&i<10;++i){ForecastDay& d=forecast[i];if(!d.valid)continue;String label=html(d.condition);String rain="&mdash;";if(isfinite(d.precip)){char q[24];snprintf(q,sizeof(q),"%.0f%%",d.precip);rain=q;if(d.precipType.length())rain=html(d.precipType)+" "+rain;}char hi[20],lo[20];snprintf(hi,sizeof(hi),"HI %.0f&deg;",d.high);snprintf(lo,sizeof(lo),"LO %.0f&deg;",d.low);page+="<div class=day><b>"+webDay(d.start,i==0)+"</b>"+webIcon(d.condition)+"<div class=cond>"+label+"</div><div class=hi>"+hi+"</div><div class=lo>"+lo+"</div><div class=rain>PRECIP "+rain+"</div><div class=sunblock><div class=sun>Sunrise "+centralClock(d.sunrise)+"</div><div class=sun>Sunset "+centralClock(d.sunset)+"</div></div></div>";}
    page+="</div>";
  }page+="<p><a href='/'>Open setup page</a></p></main></body></html>";server.send(200,"text/html",page);}
void startServer(){if(serverStarted)return;server.on("/weather",HTTP_GET,weatherPage);server.on("/",HTTP_GET,[](){String z=savedZone(),clock=savedTimeFormat();
    // This ESP32 can join 2.4 GHz Wi-Fi only. Scan while the setup page is
    // opened so the person setting it up can select a nearby network by name.
    String networkChoices="";int found=WiFi.scanNetworks();
    for(int i=0;i<found;++i){String name=WiFi.SSID(i);if(!name.length())continue;networkChoices+="<option value='"+html(name)+"'>"+html(name)+"</option>";}
    WiFi.scanDelete();
    if(!networkChoices.length())networkChoices="<option value=''>No nearby 2.4 GHz networks found. Refresh to scan again.</option>";
    String p=String("<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'><h2>Tempest Weather Setup</h2><p><b>Setup Wi-Fi:</b> Tempest-Setup<br><b>Setup password:</b> weather-setup</p><p><a href=/weather>View latest weather</a></p><form method=post action=/save>Available 2.4 GHz Wi-Fi network<br><select name=ssid>"+networkChoices+"</select><br><br>Wi-Fi password<br><input name=pass type=password><br><br>Tempest station number<br><input name=station value='"+stationId()+"'><br><br>Tempest read-only token<br><input name=token type=password><br><br>Local time zone<br><select name=tz>");const char*v[]={"EST5EDT,M3.2.0,M11.1.0","CST6CDT,M3.2.0,M11.1.0","MST7MDT,M3.2.0,M11.1.0","PST8PDT,M3.2.0,M11.1.0","AKST9AKDT,M3.2.0,M11.1.0","HST10"};const char*n[]={"Eastern Time (ET): UTC-05:00","Central Time (CT): UTC-06:00","Mountain Time (MT): UTC-07:00","Pacific Time (PT): UTC-08:00","Alaska Time (AKT): UTC-09:00","Hawaii-Aleutian Time (HAT): UTC-10:00"};for(int i=0;i<6;++i)p+="<option value='"+String(v[i])+"'"+(z==v[i]?" selected":"")+">"+n[i]+"</option>";p += "</select><br><br>Clock display<br><select name='clock'>";
    p += "<option value='12'";
    if (clock == "12") p += " selected";
    p += ">12-hour time (AM/PM)</option>";
    p += "<option value='24'";
    if (clock == "24") p += " selected";
    p += ">24-hour time</option></select><br><br><button>Save</button></form>";
    server.send(200,"text/html",p);});server.on("/save",HTTP_POST,[](){String id=server.arg("station"),z=server.arg("tz"),clock=server.arg("clock");for(char c:id)if(c<'0'||c>'9'){server.send(400,"text/plain","Enter a numeric Tempest station number.");return;}if(!id.length()||!validZone(z)||(clock!="12"&&clock!="24")){server.send(400,"text/plain","Choose a station number, time zone, and clock display from the lists.");return;}prefs.begin("weather",false);if(server.arg("ssid").length()){prefs.putString("ssid",server.arg("ssid"));prefs.putString("pass",server.arg("pass"));}prefs.putString("station",id);if(server.arg("token").length())prefs.putString("token",server.arg("token"));prefs.putString("tz",z);prefs.putString("clock12",clock);prefs.end();server.send(200,"text/html","<h2>Saved.</h2><p>Connecting to your Wi-Fi and updating the display now.</p>");delay(250);if(connectWiFi()){setupMode=false;fetchWeather();lastFetch=millis();}else{status("WIFI CONNECTION FAILED","CHECK NAME AND PASSWORD","OPEN 192.168.4.1");}});server.begin();serverStarted=true;}
bool connectWiFi(){prefs.begin("weather",true);String s=prefs.getString("ssid","");String p=prefs.getString("pass","");prefs.end();if(!s.length())return false;
#if TEMPEST_PANEL_2IN13
  constexpr char HOST_NAME[]="tempest-weather";
#else
  constexpr char HOST_NAME[]="tempest-weather-579";
#endif
  WiFi.mode(WIFI_STA);WiFi.setHostname(HOST_NAME);WiFi.begin(s.c_str(),p.c_str());uint32_t until=millis()+20000;while(WiFi.status()!=WL_CONNECTED&&millis()<until)delay(100);if(WiFi.status()!=WL_CONNECTED)return false;startServer();if(MDNS.begin(HOST_NAME)){MDNS.addService("http","tcp",80);Serial.printf("Local address ready: http://%s.local/\n",HOST_NAME);}return true;}
void setup(){Serial.begin(115200);delay(300);
  if(!panelBegin()){
    Serial.printf("Tempest display startup failed: %s\n",panelName());
  }else if(!panelClear()){
    Serial.printf("Tempest display clear failed: %s\n",panelName());
  }else{
    panelOk=true;
    Serial.printf("Tempest display ready: %s\n",panelName());
  }
  #if !TEMPEST_PANEL_2IN13
  for(uint8_t pin:NAV_PINS)pinMode(pin,INPUT_PULLUP);
#endif
  if(connectWiFi()){fetchWeather();lastFetch=millis();}else{WiFi.mode(WIFI_AP);WiFi.softAP("Tempest-Setup","weather-setup");setupMode=true;startServer();status("SETUP WIFI","JOIN TEMPEST-SETUP","PASSWORD WEATHER-SETUP");}}
void loop(){
  if(serverStarted)server.handleClient();
  pollDisplayButtons();
  // Rejoin the saved home network after a router restart or brief Wi-Fi loss.
  // The setup access point is left alone until credentials have been saved.
  static uint32_t lastReconnectAt=0;
  if(!setupMode&&WiFi.status()!=WL_CONNECTED&&millis()-lastReconnectAt>=30000UL){
    lastReconnectAt=millis();
    Serial.println("Tempest stage Wi-Fi: reconnecting");
    connectWiFi();
  }
  if(!setupMode&&WiFi.status()==WL_CONNECTED&&millis()-lastFetch>=WEATHER_REFRESH_MS){
    lastFetch=millis();
    fetchWeather();
  }
  delay(10);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik