Files
Arduino/docs/superpowers/plans/2026-07-17-weather-predictor.md
T

52 KiB

Weather Predictor Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build an autonomous barometric weather station on a Wemos D1 mini that predicts local weather with the Zambretti algorithm and shows readings + forecast on a TFT and a built-in web page.

Architecture: A single Arduino IDE sketch (WeatherPredictor/) split into focused modules (sensors, rtc, display, forecast, history, settings, net, web). setup() initializes hardware and storage; loop() runs a millis-based scheduler that samples the BMP180, keeps a pressure history in RAM (periodically flushed to LittleFS), computes a Zambretti forecast, renders the display, and serves a web UI/REST API.

Tech Stack: ESP8266 (Wemos D1 mini), Arduino IDE, Adafruit_GFX + Adafruit_ST7735, Adafruit_BMP085 (BMP180), RTClib (DS3231), LittleFS, ArduinoJson (v7), WiFiManager (tzapu), ESP8266WebServer.

Global Constraints

  • Language: all on-device and web text in English only (no Cyrillic fonts).
  • Board: Wemos D1 mini (ESP8266). Board package: "LOLIN(WEMOS) D1 R2 & mini".
  • Testing: manual, on hardware. No automated test harness. Every task ends by building, uploading, observing the stated output (Serial Monitor at 115200 baud, the TFT, or a browser), then committing.
  • Pins (fixed, from config.h): TFT CS=D8, DC=D3, RST=D4, BLK=D2, SPI SCK=D5/MOSI=D7; I2C SDA=D6, SCL=D1.
  • I2C addresses: BMP180 = 0x77, DS3231 = 0x68.
  • Defaults (editable via web): altitude 150 m, timezone UTC+7 (420 min), coordinates 54.9870 N / 82.8730 E.
  • Zambretti: northern-hemisphere only; sea-level pressure used everywhere the algorithm needs pressure.
  • Sketch folder must equal the .ino name: WeatherPredictor/WeatherPredictor.ino.
  • Commit style: conventional commits; end message body with Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>.

File Structure

All files live in the sketch folder WeatherPredictor/ (Arduino IDE shows each as a tab):

File Responsibility
WeatherPredictor.ino setup()/loop(), millis scheduler, wires modules together, owns g_state
config.h Pins, I2C addresses, intervals, buffer sizes, defaults, NTP server, AP name
state.h AppState struct (latest readings + forecast) shared with the web server
sensors.h / sensors.cpp BMP180 read (abs pressure, temp) + sea-level conversion
rtc_time.h / rtc_time.cpp DS3231 read/set + NTP sync
display_ui.h / display_ui.cpp ST7735 init + screen rendering + weather icons
forecast.h / forecast.cpp Trend classification + Zambretti forecast + category
history.h / history.cpp RAM ring buffer, 3 h trend delta, LittleFS persistence
app_settings.h / app_settings.cpp Settings struct + defaults + LittleFS JSON load/save
net.h / net.cpp Wi-Fi connect via WiFiManager
web_server.h / web_server.cpp HTTP server + REST API
web_page.h Web UI (HTML/CSS/JS) as a PROGMEM string

Prerequisite (do once before Task 1): In Arduino IDE install ESP8266 board package and these libraries via Library Manager: Adafruit GFX Library, Adafruit ST7735 and ST7789 Library, Adafruit BMP085 Library, RTClib, ArduinoJson (v7.x), WiFiManager by tapzu. LittleFS, ESP8266WebServer, Wire, SPI, time are bundled with the ESP8266 core.


Task 1: Scaffold + I2C bring-up

Creates the sketch, config.h, and a boot sequence that starts I2C on D6/D1 and scans the bus. This verifies the BMP180 and DS3231 wiring before any driver code exists.

Files:

  • Create: WeatherPredictor/WeatherPredictor.ino
  • Create: WeatherPredictor/config.h

Interfaces:

  • Produces: all config.h macros/constants consumed by every later task.

  • Step 1: Create config.h

#pragma once
#include <Arduino.h>

// ---- TFT (hardware SPI: SCK=D5, MOSI=D7) ----
#define TFT_CS   D8
#define TFT_DC   D3
#define TFT_RST  D4
#define TFT_BLK  D2

// ---- I2C (BMP180 + DS3231 share this bus) ----
#define I2C_SDA  D6
#define I2C_SCL  D1
#define BMP180_ADDR 0x77
#define DS3231_ADDR 0x68

// ---- Scheduler intervals (ms) ----
// NOTE: for quick bench testing you may temporarily shrink these.
static const unsigned long SAMPLE_INTERVAL_MS  = 60UL * 1000UL;         // read sensor / redraw
static const unsigned long HISTORY_INTERVAL_MS = 5UL * 60UL * 1000UL;   // store a history sample
static const unsigned long FLUSH_INTERVAL_MS   = 15UL * 60UL * 1000UL;  // flush history to flash

// ---- History buffer ----
static const int   HISTORY_SIZE       = 288;   // 24 h @ 5 min
static const float TREND_THRESHOLD_HPA = 1.6f; // >|1.6| hPa / 3 h = rising/falling

// ---- Defaults (editable via web) ----
static const float DEFAULT_ALTITUDE_M    = 150.0f;
static const int   DEFAULT_TZ_OFFSET_MIN = 7 * 60; // UTC+7
static const float DEFAULT_LAT           = 54.9870f;
static const float DEFAULT_LON           = 82.8730f;

// ---- Network ----
#define NTP_SERVER "pool.ntp.org"
#define AP_NAME    "WeatherPredictor-Setup"
  • Step 2: Create WeatherPredictor.ino (scaffold with I2C scanner)
#include <Arduino.h>
#include <Wire.h>
#include "config.h"

static void i2cScan() {
  Serial.println(F("I2C scan:"));
  byte found = 0;
  for (byte addr = 1; addr < 127; addr++) {
    Wire.beginTransmission(addr);
    if (Wire.endTransmission() == 0) {
      Serial.printf("  device at 0x%02X\n", addr);
      found++;
    }
  }
  Serial.printf("  %d device(s) found\n", found);
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println(F("\n=== Weather Predictor booting ==="));
  Wire.begin(I2C_SDA, I2C_SCL);   // MUST come before any I2C driver begin()
  i2cScan();
}

void loop() {
}
  • Step 3: Build & upload

Arduino IDE → select board "LOLIN(WEMOS) D1 R2 & mini", correct COM port → Upload. Expected: compiles and uploads with no errors.

  • Step 4: Verify on Serial Monitor (115200)

Expected output includes both addresses:

=== Weather Predictor booting ===
I2C scan:
  device at 0x68
  device at 0x77
  2 device(s) found

If a device is missing: check wiring (SDA=D6, SCL=D1, 3.3 V/GND) and that Wire.begin(I2C_SDA, I2C_SCL) runs before anything else. Do not proceed until both appear.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: sketch scaffold with I2C bus bring-up and scanner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 2: Display module + splash

Brings up the ST7735 and draws a splash, proving the display path inside this project (the user already validated the wiring separately).

Files:

  • Create: WeatherPredictor/display_ui.h
  • Create: WeatherPredictor/display_ui.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces: void displayBegin(); and void displaySplash(const char* line1, const char* line2); (consumed by Task 8).

  • Step 1: Create display_ui.h

#pragma once
#include <Arduino.h>

void displayBegin();
void displaySplash(const char* line1, const char* line2);
  • Step 2: Create display_ui.cpp
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>
#include "config.h"
#include "display_ui.h"

static Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);

void displayBegin() {
  pinMode(TFT_BLK, OUTPUT);
  digitalWrite(TFT_BLK, HIGH);           // backlight on
  tft.initR(INITR_MINI160x80);           // 0.96" 80x160 ST7735S
  tft.invertDisplay(true);               // this panel needs inversion; flip if colours look wrong
  tft.setRotation(0);                    // portrait, 80 wide x 160 tall
  tft.fillScreen(ST77XX_BLACK);
}

void displaySplash(const char* line1, const char* line2) {
  tft.fillScreen(ST77XX_BLACK);
  tft.setTextColor(ST77XX_WHITE);
  tft.setTextSize(2);
  tft.setCursor(4, 40);
  tft.print(line1);
  tft.setTextSize(1);
  tft.setCursor(4, 70);
  tft.print(line2);
}
  • Step 3: Call it from setup()

In WeatherPredictor.ino add the include and calls:

#include "display_ui.h"

Add at the end of setup():

  displayBegin();
  displaySplash("Weather", "Predictor v0.1");
  • Step 4: Build, upload, verify on screen

Expected: screen shows "Weather" (large) and "Predictor v0.1" (small) on black. If colours are inverted/wrong, toggle the tft.invertDisplay(true) argument or swap to your known-good init line from the sketch that already worked.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: TFT display init and splash screen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: BMP180 sensor + sea-level conversion

Reads absolute pressure and temperature and converts to mean-sea-level pressure using the barometric formula.

Files:

  • Create: WeatherPredictor/sensors.h
  • Create: WeatherPredictor/sensors.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces:

    • bool sensorsBegin();
    • float readAbsPressureHpa();
    • float readTemperatureC();
    • float toSeaLevelHpa(float absHpa, float altitudeM);
  • Step 1: Create sensors.h

#pragma once
#include <Arduino.h>

bool  sensorsBegin();
float readAbsPressureHpa();                     // absolute (station) pressure, hPa
float readTemperatureC();                       // degrees C
float toSeaLevelHpa(float absHpa, float altitudeM); // mean-sea-level pressure, hPa
  • Step 2: Create sensors.cpp
#include <Adafruit_BMP085.h>
#include <math.h>
#include "sensors.h"

static Adafruit_BMP085 bmp;

bool sensorsBegin() {
  return bmp.begin();          // uses Wire (already started on D6/D1), addr 0x77
}

float readAbsPressureHpa() {
  return bmp.readPressure() / 100.0f;   // Pa -> hPa
}

float readTemperatureC() {
  return bmp.readTemperature();
}

// Standard barometric reduction to sea level.
float toSeaLevelHpa(float absHpa, float altitudeM) {
  return absHpa / powf(1.0f - (altitudeM / 44330.0f), 5.255f);
}
  • Step 3: Wire into setup()/loop() for a bench read

In WeatherPredictor.ino add #include "sensors.h". In setup() after Wire.begin(...):

  if (!sensorsBegin()) Serial.println(F("BMP180 not found!"));

Replace the empty loop() with a temporary 2 s print (removed in Task 8):

void loop() {
  float abs_ = readAbsPressureHpa();
  float msl  = toSeaLevelHpa(abs_, DEFAULT_ALTITUDE_M);
  Serial.printf("abs=%.1f hPa  msl=%.1f hPa  t=%.1f C\n",
                abs_, msl, readTemperatureC());
  delay(2000);
}
  • Step 4: Build, upload, verify on Serial

Expected: plausible values, e.g. abs=993.4 hPa msl=1011.8 hPa t=24.3 C. Sanity: at 150 m, msl should be roughly abs + 18 hPa; temperature near room/ambient.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: BMP180 read and sea-level pressure conversion

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: DS3231 real-time clock

Reads and sets time; detects a lost-power (uninitialized) clock.

Files:

  • Create: WeatherPredictor/rtc_time.h
  • Create: WeatherPredictor/rtc_time.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces:

    • struct RtcTime { uint16_t year; uint8_t month, day, hour, minute, second; };
    • bool rtcBegin();
    • bool rtcLostPower();
    • RtcTime rtcNow();
    • void rtcSet(const RtcTime& t);
    • uint32_t rtcEpoch(); // unix time as held by the DS3231 (treated as local)
  • Step 1: Create rtc_time.h

#pragma once
#include <Arduino.h>

struct RtcTime {
  uint16_t year;
  uint8_t  month, day, hour, minute, second;
};

bool     rtcBegin();
bool     rtcLostPower();
RtcTime  rtcNow();
void     rtcSet(const RtcTime& t);
uint32_t rtcEpoch();
  • Step 2: Create rtc_time.cpp
#include <RTClib.h>
#include "rtc_time.h"

static RTC_DS3231 rtc;

bool rtcBegin()      { return rtc.begin(); }
bool rtcLostPower()  { return rtc.lostPower(); }

RtcTime rtcNow() {
  DateTime n = rtc.now();
  return RtcTime{ n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second() };
}

void rtcSet(const RtcTime& t) {
  rtc.adjust(DateTime(t.year, t.month, t.day, t.hour, t.minute, t.second));
}

uint32_t rtcEpoch() {
  return rtc.now().unixtime();
}
  • Step 3: Wire into setup() — init, set once if needed, print

In WeatherPredictor.ino add #include "rtc_time.h". In setup():

  if (!rtcBegin()) Serial.println(F("DS3231 not found!"));
  if (rtcLostPower()) {
    Serial.println(F("RTC lost power -> setting to build time"));
    rtcSet(RtcTime{2026, 7, 17, 12, 0, 0});  // temporary; NTP will correct later
  }

Change the temporary loop() print to include time:

void loop() {
  RtcTime t = rtcNow();
  Serial.printf("%04u-%02u-%02u %02u:%02u:%02u  abs=%.1f msl=%.1f t=%.1f\n",
                t.year, t.month, t.day, t.hour, t.minute, t.second,
                readAbsPressureHpa(),
                toSeaLevelHpa(readAbsPressureHpa(), DEFAULT_ALTITUDE_M),
                readTemperatureC());
  delay(2000);
}
  • Step 4: Build, upload, verify + power-cycle test

Expected: time prints and increments each 2 s. Then unplug and replug the board: time should continue from where it was (battery-backed), not reset to 12:00:00 — confirming the coin cell works. (rtcLostPower() only fires on first run / dead battery.)

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: DS3231 RTC read/set with lost-power detection

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Zambretti forecast

Pure forecasting logic: classify a 3 h pressure delta into a trend, then map sea-level pressure + trend + month to a Zambretti forecast letter, text, and display category. Constants and tables from the canonical G6EJD implementation.

Files:

  • Create: WeatherPredictor/forecast.h
  • Create: WeatherPredictor/forecast.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces:

    • enum Trend { TREND_FALLING = -1, TREND_STEADY = 0, TREND_RISING = 1 };
    • enum WxCategory { WX_FINE, WX_FAIR, WX_CHANGEABLE, WX_RAIN, WX_STORM, WX_UNKNOWN };
    • struct Forecast { char letter; const char* text; WxCategory category; };
    • Trend classifyTrend(float deltaHpa3h);
    • Forecast computeForecast(float mslHpa, Trend trend, int month);
    • const char* categoryShort(WxCategory c);
  • Step 1: Create forecast.h

#pragma once
#include <Arduino.h>

enum Trend { TREND_FALLING = -1, TREND_STEADY = 0, TREND_RISING = 1 };
enum WxCategory { WX_FINE, WX_FAIR, WX_CHANGEABLE, WX_RAIN, WX_STORM, WX_UNKNOWN };

struct Forecast {
  char        letter;    // 'A'..'Z'
  const char* text;      // full Zambretti phrase
  WxCategory  category;  // for display icon + short label
};

Trend       classifyTrend(float deltaHpa3h);
Forecast    computeForecast(float mslHpa, Trend trend, int month);
const char* categoryShort(WxCategory c);
  • Step 2: Create forecast.cpp
#include <math.h>
#include "config.h"
#include "forecast.h"

// Full Zambretti phrases, index 0='A' .. 25='Z'.
static const char* const ZTEXT[26] = {
  "Settled fine weather",                    // A
  "Fine weather",                            // B
  "Becoming fine",                           // C
  "Fine, becoming less settled",             // D
  "Fine, possibly showers",                  // E
  "Fairly fine, improving",                  // F
  "Fairly fine, possibly showers early",     // G
  "Fairly fine, showers later",              // H
  "Showery early, improving",                // I
  "Changeable, improving",                   // J
  "Fairly fine, showers likely",             // K
  "Rather unsettled, clearing later",        // L
  "Unsettled, probably improving",           // M
  "Showery, bright intervals",               // N
  "Showery, becoming unsettled",             // O
  "Changeable, some rain",                   // P
  "Unsettled, short fine intervals",         // Q
  "Unsettled, rain later",                   // R
  "Unsettled, rain at times",                // S
  "Very unsettled, finer at times",          // T
  "Rain at times, worse later",              // U
  "Rain at times, becoming very unsettled",  // V
  "Rain at frequent intervals",              // W
  "Very unsettled, rain",                    // X
  "Stormy, possibly improving",              // Y
  "Stormy, much rain"                        // Z
};

// Map a constrained Zambretti number to a letter, per trend (G6EJD tables).
static char letterRising(int z)  { const char* m = "ABCFGIJLMQTYZ"; return m[z - 1]; } // z 1..13
static char letterFalling(int z) { const char* m = "ABDHORUXZ";     return m[z - 1]; } // z 1..9
static char letterSteady(int z)  { const char* m = "ABEKNPSWXZ";    return m[z - 1]; } // z 1..10

static int clampi(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }

static WxCategory categoryOf(char letter) {
  switch (letter) {
    case 'A': case 'B': case 'C': case 'F':                       return WX_FINE;
    case 'E': case 'G': case 'I': case 'J': case 'K':
    case 'M': case 'N': case 'Q':                                 return WX_FAIR;
    case 'D': case 'H': case 'L': case 'O': case 'P':             return WX_CHANGEABLE;
    case 'R': case 'S': case 'T': case 'U': case 'V': case 'W':
    case 'X':                                                     return WX_RAIN;
    case 'Y': case 'Z':                                           return WX_STORM;
    default:                                                      return WX_UNKNOWN;
  }
}

Trend classifyTrend(float d) {
  if (d >  TREND_THRESHOLD_HPA) return TREND_RISING;
  if (d < -TREND_THRESHOLD_HPA) return TREND_FALLING;
  return TREND_STEADY;
}

Forecast computeForecast(float p, Trend trend, int month) {
  bool winter = (month < 4 || month > 9);   // northern hemisphere
  int z;
  char letter;

  if (trend == TREND_RISING) {
    z = (int)lround(-0.1449 * p + 150.18);
    if (winter) z += 1;
    z = clampi(z, 1, 13);
    letter = letterRising(z);
  } else if (trend == TREND_FALLING) {
    z = (int)lround(0.0000257935 * p * p * p
                    - 0.078482105 * p * p
                    + 79.4582219457 * p
                    - 26762.7164899421);
    if (winter) z -= 1;
    z = clampi(z, 1, 9);
    letter = letterFalling(z);
  } else {
    z = (int)lround(0.0000258964 * p * p * p
                    - 0.07753778137 * p * p
                    + 77.2287820569 * p
                    - 25582.130426005);
    z = clampi(z, 1, 10);
    letter = letterSteady(z);
  }

  return Forecast{ letter, ZTEXT[letter - 'A'], categoryOf(letter) };
}

const char* categoryShort(WxCategory c) {
  switch (c) {
    case WX_FINE:       return "Fine";
    case WX_FAIR:       return "Fair";
    case WX_CHANGEABLE: return "Changeable";
    case WX_RAIN:       return "Rain";
    case WX_STORM:      return "Storm";
    default:            return "...";
  }
}
  • Step 3: Add a temporary self-test in setup()

In WeatherPredictor.ino add #include "forecast.h". At the end of setup():

  // --- forecast self-test (remove after Task 8) ---
  struct { float p; Trend tr; } cases[] = {
    {1030, TREND_STEADY}, {1030, TREND_RISING}, {1030, TREND_FALLING},
    {1000, TREND_STEADY}, {1000, TREND_FALLING}, {970, TREND_FALLING},
  };
  for (auto& c : cases) {
    Forecast f = computeForecast(c.p, c.tr, 7);
    Serial.printf("p=%.0f trend=%d -> %c [%s] (%s)\n",
                  c.p, c.tr, f.letter, f.text, categoryShort(f.category));
  }
  • Step 4: Build, upload, verify against expectations

Expected (July / summer, month=7): high pressure trends toward fine, low + falling toward stormy, e.g.:

p=1030 trend=0 -> A [Settled fine weather] (Fine)
p=1030 trend=1 -> A [Settled fine weather] (Fine)
p=1030 trend=-1 -> A [Settled fine weather] (Fine)
p=1000 trend=0 -> ... (Fair/Changeable)
p=1000 trend=-1 -> ... (Changeable/Rain)
p=970  trend=-1 -> Z [Stormy, much rain] (Storm)

Confirm high pressure → "Fine" and ~970 hPa falling → "Stormy". Exact letters may differ slightly; the trend of categories from Fine→Storm as pressure drops is what to verify.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: Zambretti forecast with trend classification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: Pressure history ring buffer + 3 h trend

In-RAM ring buffer of samples plus a 3-hour trend delta. No flash yet (added in Task 9).

Files:

  • Create: WeatherPredictor/history.h
  • Create: WeatherPredictor/history.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Consumes: config.h (HISTORY_SIZE).

  • Produces:

    • struct Sample { uint32_t epoch; float mslHpa; float tempC; };
    • void historyBegin();
    • void historyAdd(uint32_t epoch, float mslHpa, float tempC);
    • int historyCount();
    • Sample historyGet(int i); // 0 = oldest
    • Sample historyLatest();
    • bool historyTrendDelta(float& outDeltaHpa); // delta over ~3 h; false if <2.5 h of data
  • Step 1: Create history.h

#pragma once
#include <Arduino.h>

struct Sample {
  uint32_t epoch;
  float    mslHpa;
  float    tempC;
};

void   historyBegin();
void   historyAdd(uint32_t epoch, float mslHpa, float tempC);
int    historyCount();
Sample historyGet(int i);      // 0 = oldest
Sample historyLatest();
bool   historyTrendDelta(float& outDeltaHpa);
  • Step 2: Create history.cpp
#include "config.h"
#include "history.h"

static Sample s_buf[HISTORY_SIZE];
static int    s_head  = 0;   // next write position
static int    s_count = 0;

void historyBegin() { s_head = 0; s_count = 0; }

void historyAdd(uint32_t epoch, float mslHpa, float tempC) {
  s_buf[s_head] = Sample{ epoch, mslHpa, tempC };
  s_head = (s_head + 1) % HISTORY_SIZE;
  if (s_count < HISTORY_SIZE) s_count++;
}

int historyCount() { return s_count; }

Sample historyGet(int i) {
  int start = (s_head - s_count + HISTORY_SIZE) % HISTORY_SIZE;
  return s_buf[(start + i) % HISTORY_SIZE];
}

Sample historyLatest() { return historyGet(s_count - 1); }

bool historyTrendDelta(float& outDelta) {
  if (s_count < 2) return false;
  Sample latest = historyLatest();
  uint32_t target = latest.epoch - 10800UL;   // 3 h earlier
  int idx = -1;
  for (int i = 0; i < s_count; i++) {
    if (historyGet(i).epoch <= target) idx = i; else break;
  }
  if (idx < 0) return false;
  Sample past = historyGet(idx);
  if (latest.epoch - past.epoch < 9000UL) return false;  // need >= 2.5 h span
  outDelta = latest.mslHpa - past.mslHpa;
  return true;
}
  • Step 3: Temporary self-test in setup()

In WeatherPredictor.ino add #include "history.h". At end of setup():

  // --- history self-test (remove after Task 8) ---
  historyBegin();
  uint32_t base = 1000000000UL;
  for (int i = 0; i < 40; i++)                 // 40 samples @ 5 min = 3h20m, pressure falling
    historyAdd(base + (uint32_t)i * 300, 1015.0f - i * 0.2f, 20.0f);
  float d;
  if (historyTrendDelta(d))
    Serial.printf("history count=%d  3h delta=%.2f hPa  trend=%d\n",
                  historyCount(), d, classifyTrend(d));
  else
    Serial.println(F("history: not enough data for trend"));
  • Step 4: Build, upload, verify

Expected: a negative delta (~ -7 hPa over the ~3.25 h window) classified as falling:

history count=40  3h delta=-7.20 hPa  trend=-1
  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: in-RAM pressure history ring buffer with 3h trend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 7: Settings (LittleFS JSON)

Persistent settings with defaults, stored as JSON in LittleFS.

Files:

  • Create: WeatherPredictor/app_settings.h
  • Create: WeatherPredictor/app_settings.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces:

    • struct AppSettings { float altitudeM; int tzOffsetMin; float lat; float lon; };
    • extern AppSettings settings;
    • void settingsBegin(); // mount FS + load (or write defaults)
    • bool settingsSave();
    • void settingsDefaults();
  • Step 1: Create app_settings.h

#pragma once
#include <Arduino.h>

struct AppSettings {
  float altitudeM;
  int   tzOffsetMin;
  float lat;
  float lon;
};

extern AppSettings settings;

void settingsBegin();
bool settingsSave();
void settingsDefaults();
  • Step 2: Create app_settings.cpp
#include <LittleFS.h>
#include <ArduinoJson.h>
#include "config.h"
#include "app_settings.h"

AppSettings settings;
static const char* PATH = "/settings.json";

void settingsDefaults() {
  settings.altitudeM   = DEFAULT_ALTITUDE_M;
  settings.tzOffsetMin = DEFAULT_TZ_OFFSET_MIN;
  settings.lat         = DEFAULT_LAT;
  settings.lon         = DEFAULT_LON;
}

static bool settingsLoad() {
  File f = LittleFS.open(PATH, "r");
  if (!f) return false;
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, f);
  f.close();
  if (err) return false;
  settings.altitudeM   = doc["altitude"] | DEFAULT_ALTITUDE_M;
  settings.tzOffsetMin = doc["tz"]       | DEFAULT_TZ_OFFSET_MIN;
  settings.lat         = doc["lat"]      | DEFAULT_LAT;
  settings.lon         = doc["lon"]      | DEFAULT_LON;
  return true;
}

bool settingsSave() {
  JsonDocument doc;
  doc["altitude"] = settings.altitudeM;
  doc["tz"]       = settings.tzOffsetMin;
  doc["lat"]      = settings.lat;
  doc["lon"]      = settings.lon;
  File f = LittleFS.open(PATH, "w");
  if (!f) return false;
  serializeJson(doc, f);
  f.close();
  return true;
}

void settingsBegin() {
  if (!LittleFS.begin()) {
    LittleFS.format();
    LittleFS.begin();
  }
  settingsDefaults();
  if (!settingsLoad()) {   // first boot: persist defaults
    settingsSave();
  }
}
  • Step 3: Temporary self-test in setup()

In WeatherPredictor.ino add #include "app_settings.h". At end of setup():

  // --- settings self-test (remove after Task 8) ---
  settingsBegin();
  Serial.printf("settings: alt=%.0f tz=%d lat=%.4f lon=%.4f\n",
                settings.altitudeM, settings.tzOffsetMin, settings.lat, settings.lon);
  settings.altitudeM += 1.0f;   // change and persist to test round-trip
  settingsSave();
  Serial.println(F("settings: incremented altitude and saved"));
  • Step 4: Build, upload, verify persistence across reboots

First boot prints alt=150. Each subsequent reset/power-cycle should print an altitude one higher than before (151, 152, ...), proving load+save survive reboot. Then remove the += 1.0f and save line and re-upload so altitude stabilizes.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: persistent settings in LittleFS JSON

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 8: Main integration + full display layout

Replaces all temporary self-tests with the real scheduler and status screen. This is the first "finished device" milestone (offline: reads, forecasts, displays).

Files:

  • Create: WeatherPredictor/state.h
  • Modify: WeatherPredictor/display_ui.h
  • Modify: WeatherPredictor/display_ui.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino (rewrite)

Interfaces:

  • Produces:

    • state.h: struct AppState { float absHpa, mslHpa, tempC; Trend trend; Forecast forecast; RtcTime now; bool haveTrend; }; and extern AppState g_state;
    • display_ui: void displayRender(const AppState& s);
  • Step 1: Create state.h

#pragma once
#include "rtc_time.h"
#include "forecast.h"

struct AppState {
  float    absHpa;
  float    mslHpa;
  float    tempC;
  Trend    trend;
  Forecast forecast;
  RtcTime  now;
  bool     haveTrend;
};

extern AppState g_state;
  • Step 2: Add displayRender declaration to display_ui.h
#include "state.h"
void displayRender(const AppState& s);

(Add these two lines after the existing declarations.)

  • Step 3: Implement displayRender + icons in display_ui.cpp

Add #include "state.h" at the top, then append:

static void drawIcon(Adafruit_ST7735& d, WxCategory c, int x, int y) {
  // 34x34 box starting at (x,y)
  uint16_t sun   = ST77XX_YELLOW;
  uint16_t cloud = ST77XX_WHITE;
  uint16_t rain  = ST77XX_CYAN;
  switch (c) {
    case WX_FINE:
      d.fillCircle(x + 17, y + 17, 11, sun);
      break;
    case WX_FAIR:
      d.fillCircle(x + 12, y + 12, 8, sun);
      d.fillRoundRect(x + 8, y + 18, 24, 12, 6, cloud);
      break;
    case WX_CHANGEABLE:
      d.fillRoundRect(x + 4, y + 12, 26, 14, 7, cloud);
      break;
    case WX_RAIN:
      d.fillRoundRect(x + 4, y + 8, 26, 14, 7, cloud);
      for (int i = 0; i < 3; i++)
        d.drawFastVLine(x + 9 + i * 8, y + 24, 8, rain);
      break;
    case WX_STORM:
      d.fillRoundRect(x + 4, y + 8, 26, 14, 7, cloud);
      d.fillTriangle(x + 16, y + 22, x + 12, y + 32, x + 20, y + 30, ST77XX_YELLOW);
      break;
    default:
      d.drawRect(x + 4, y + 10, 26, 18, cloud);  // unknown / collecting
      break;
  }
}

void displayRender(const AppState& s) {
  extern Adafruit_ST7735 tft;   // defined at top of this file
  tft.fillScreen(ST77XX_BLACK);
  tft.setTextColor(ST77XX_WHITE);

  // Time (large)
  char buf[24];
  snprintf(buf, sizeof(buf), "%02u:%02u", s.now.hour, s.now.minute);
  tft.setTextSize(2);
  tft.setCursor(6, 4);
  tft.print(buf);

  // Date
  snprintf(buf, sizeof(buf), "%04u-%02u-%02u", s.now.year, s.now.month, s.now.day);
  tft.setTextSize(1);
  tft.setCursor(6, 26);
  tft.print(buf);

  // Pressure + trend arrow
  tft.setTextSize(1);
  tft.setCursor(6, 42);
  snprintf(buf, sizeof(buf), "%.0f hPa", s.mslHpa);
  tft.print(buf);
  const char* arrow = (s.trend == TREND_RISING) ? "^" : (s.trend == TREND_FALLING ? "v" : "=");
  tft.setCursor(64, 42);
  tft.print(arrow);

  // Temperature
  tft.setCursor(6, 54);
  snprintf(buf, sizeof(buf), "%.1f C", s.tempC);
  tft.print(buf);

  // Icon
  drawIcon(tft, s.haveTrend ? s.forecast.category : WX_UNKNOWN, 40, 70);

  // Forecast short label (wraps in the web; here short category)
  tft.setTextSize(1);
  tft.setCursor(2, 112);
  tft.print(s.haveTrend ? categoryShort(s.forecast.category) : "Collecting");
}

Change static Adafruit_ST7735 tft = ... at the top of the file to non-static (remove static) so displayRender's extern reference links.

  • Step 4: Rewrite WeatherPredictor.ino with the real scheduler
#include <Arduino.h>
#include <Wire.h>
#include "config.h"
#include "sensors.h"
#include "rtc_time.h"
#include "display_ui.h"
#include "forecast.h"
#include "history.h"
#include "app_settings.h"
#include "state.h"

AppState g_state;

static unsigned long tSample  = 0;
static unsigned long tHistory = 0;

static void sampleNow() {
  g_state.now    = rtcNow();
  g_state.absHpa = readAbsPressureHpa();
  g_state.mslHpa = toSeaLevelHpa(g_state.absHpa, settings.altitudeM);
  g_state.tempC  = readTemperatureC();

  float d;
  g_state.haveTrend = historyTrendDelta(d);
  if (g_state.haveTrend) {
    g_state.trend    = classifyTrend(d);
    g_state.forecast = computeForecast(g_state.mslHpa, g_state.trend, g_state.now.month);
  } else {
    g_state.trend = TREND_STEADY;
  }
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println(F("\n=== Weather Predictor ==="));
  Wire.begin(I2C_SDA, I2C_SCL);

  displayBegin();
  displaySplash("Weather", "Predictor");

  if (!sensorsBegin()) Serial.println(F("BMP180 not found!"));
  if (!rtcBegin())     Serial.println(F("DS3231 not found!"));
  if (rtcLostPower()) {
    Serial.println(F("RTC lost power -> temporary time set"));
    rtcSet(RtcTime{2026, 7, 17, 12, 0, 0});
  }
  settingsBegin();
  historyBegin();

  // Seed first sample immediately.
  sampleNow();
  historyAdd(rtcEpoch(), g_state.mslHpa, g_state.tempC);
  displayRender(g_state);
  tSample = tHistory = millis();
}

void loop() {
  unsigned long now = millis();

  if (now - tSample >= SAMPLE_INTERVAL_MS) {
    tSample = now;
    sampleNow();
    displayRender(g_state);
  }

  if (now - tHistory >= HISTORY_INTERVAL_MS) {
    tHistory = now;
    historyAdd(rtcEpoch(), g_state.mslHpa, g_state.tempC);
  }
}
  • Step 5: Build, upload, verify on screen

Expected: screen shows current time, date, sea-level pressure with a trend marker, temperature, an icon, and (until ~3 h of history exists) the label "Collecting". Time advances; pressure/temperature match Serial-era values. Leave running to confirm no crashes/resets.

  • Step 6: Commit
git add WeatherPredictor/
git commit -m "feat: integrate scheduler and full status display

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 9: History persistence to LittleFS

Flush the ring buffer to flash periodically and reload it on boot, so the 3 h trend survives restarts.

Files:

  • Modify: WeatherPredictor/history.h
  • Modify: WeatherPredictor/history.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces (added to history.h): bool historySave(); and bool historyLoad();

  • Step 1: Add declarations to history.h

bool historySave();
bool historyLoad();

(Add after the existing declarations.)

  • Step 2: Implement save/load in history.cpp

Add includes at the top:

#include <LittleFS.h>

Append:

static const char* HPATH = "/history.dat";

// Binary format: [int32 count][count * Sample], stored oldest-first.
bool historySave() {
  File f = LittleFS.open(HPATH, "w");
  if (!f) return false;
  int32_t n = s_count;
  f.write((const uint8_t*)&n, sizeof(n));
  for (int i = 0; i < s_count; i++) {
    Sample s = historyGet(i);
    f.write((const uint8_t*)&s, sizeof(s));
  }
  f.close();
  return true;
}

bool historyLoad() {
  File f = LittleFS.open(HPATH, "r");
  if (!f) return false;
  int32_t n = 0;
  if (f.read((uint8_t*)&n, sizeof(n)) != sizeof(n)) { f.close(); return false; }
  if (n < 0 || n > HISTORY_SIZE) { f.close(); return false; }
  s_head = 0; s_count = 0;
  for (int i = 0; i < n; i++) {
    Sample s;
    if (f.read((uint8_t*)&s, sizeof(s)) != sizeof(s)) break;
    historyAdd(s.epoch, s.mslHpa, s.tempC);
  }
  f.close();
  return true;
}
  • Step 3: Wire flush + restore into WeatherPredictor.ino

Add a flush timer near the other timers:

static unsigned long tFlush = 0;

In setup(), after historyBegin();, restore prior history:

  historyLoad();   // ignore result: empty on first boot

At the end of setup() set tFlush = millis();. In loop(), add:

  if (now - tFlush >= FLUSH_INTERVAL_MS) {
    tFlush = now;
    historySave();
  }
  • Step 4: Build, upload, verify persistence

Let it accumulate a few history samples (temporarily lower HISTORY_INTERVAL_MS and FLUSH_INTERVAL_MS in config.h to ~10 s / ~15 s for the test), confirm via a temporary Serial.printf("hist=%d\n", historyCount()); in loop() that count grows, then reset the board: after boot the count should resume near its previous value rather than 0. Restore the real intervals and remove the temporary print afterward.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: persist pressure history to LittleFS across reboots

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 10: Wi-Fi via WiFiManager

Connect to Wi-Fi using a captive portal so credentials are set from a phone without reflashing.

Files:

  • Create: WeatherPredictor/net.h
  • Create: WeatherPredictor/net.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces:

    • bool netBegin(); // non-blocking-ish autoConnect; true if connected
    • bool netConnected();
    • String netIP();
  • Step 1: Create net.h

#pragma once
#include <Arduino.h>

bool   netBegin();
bool   netConnected();
String netIP();
  • Step 2: Create net.cpp
#include <ESP8266WiFi.h>
#include <WiFiManager.h>
#include "config.h"
#include "net.h"

bool netBegin() {
  WiFiManager wm;
  wm.setConfigPortalTimeout(180);         // give up portal after 3 min, run offline
  bool ok = wm.autoConnect(AP_NAME);      // opens AP "WeatherPredictor-Setup" if no creds
  return ok;
}

bool   netConnected() { return WiFi.status() == WL_CONNECTED; }
String netIP()        { return WiFi.localIP().toString(); }
  • Step 3: Wire into setup()

In WeatherPredictor.ino add #include "net.h". After settingsBegin();:

  displaySplash("WiFi", "Connect / portal");
  if (netBegin()) {
    Serial.printf("WiFi connected: %s\n", netIP().c_str());
  } else {
    Serial.println(F("WiFi not connected - running offline"));
  }
  • Step 4: Build, upload, verify captive portal

First boot (no stored creds): from a phone, join Wi-Fi "WeatherPredictor-Setup", the captive portal opens, pick your network and enter its password. Board reboots and Serial prints WiFi connected: 192.168.x.x. If skipped for 3 min, it prints "running offline" and the device still works (Tasks 1-9 are offline-capable).

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: Wi-Fi provisioning via WiFiManager captive portal

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 11: NTP time sync to DS3231

When Wi-Fi is up, fetch NTP time and write it to the DS3231 using the configured timezone offset.

Files:

  • Modify: WeatherPredictor/rtc_time.h
  • Modify: WeatherPredictor/rtc_time.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Produces (added to rtc_time.h): bool ntpSync(int tzOffsetMin); // true if synced and RTC updated

  • Step 1: Add declaration to rtc_time.h

bool ntpSync(int tzOffsetMin);
  • Step 2: Implement in rtc_time.cpp

Add includes at the top:

#include <time.h>
#include "config.h"

Append:

bool ntpSync(int tzOffsetMin) {
  configTime(tzOffsetMin * 60, 0, NTP_SERVER);   // apply local offset, no DST
  time_t now = time(nullptr);
  int tries = 0;
  while (now < 1700000000 && tries < 40) {       // wait until a real epoch arrives
    delay(250);
    now = time(nullptr);
    tries++;
  }
  if (now < 1700000000) return false;
  struct tm* lt = localtime(&now);
  rtcSet(RtcTime{ (uint16_t)(lt->tm_year + 1900), (uint8_t)(lt->tm_mon + 1),
                  (uint8_t)lt->tm_mday, (uint8_t)lt->tm_hour,
                  (uint8_t)lt->tm_min, (uint8_t)lt->tm_sec });
  return true;
}
  • Step 3: Call after Wi-Fi connect in setup()

In WeatherPredictor.ino, inside the if (netBegin()) { ... } block, after the connected print:

    if (ntpSync(settings.tzOffsetMin))
      Serial.println(F("RTC synced from NTP"));
    else
      Serial.println(F("NTP sync failed"));
  • Step 4: Build, upload, verify

With Wi-Fi connected, deliberately set the RTC wrong first (temporarily rtcSet(RtcTime{2020,1,1,0,0,0}); just before the netBegin block), upload, and confirm Serial then prints "RTC synced from NTP" and the display/Serial time jumps to the correct local time (UTC+7). Remove the temporary wrong-time line afterward.

  • Step 5: Commit
git add WeatherPredictor/
git commit -m "feat: NTP time sync writing to DS3231

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 12: Web server + REST API

Serve JSON endpoints for current readings, history, and settings (GET/POST).

Files:

  • Create: WeatherPredictor/web_server.h
  • Create: WeatherPredictor/web_server.cpp
  • Modify: WeatherPredictor/WeatherPredictor.ino

Interfaces:

  • Consumes: g_state (state.h), settings (app_settings.h), history API, net.

  • Produces: void webBegin(); and void webLoop();

  • Step 1: Create web_server.h

#pragma once
void webBegin();
void webLoop();
  • Step 2: Create web_server.cpp
#include <ESP8266WebServer.h>
#include <ArduinoJson.h>
#include "state.h"
#include "app_settings.h"
#include "history.h"
#include "forecast.h"
#include "web_page.h"

static ESP8266WebServer server(80);

static void handleRoot() {
  server.send_P(200, "text/html", INDEX_HTML);
}

static void handleCurrent() {
  JsonDocument doc;
  char t[24];
  snprintf(t, sizeof(t), "%04u-%02u-%02u %02u:%02u:%02u",
           g_state.now.year, g_state.now.month, g_state.now.day,
           g_state.now.hour, g_state.now.minute, g_state.now.second);
  doc["time"]      = t;
  doc["abs"]       = g_state.absHpa;
  doc["msl"]       = g_state.mslHpa;
  doc["temp"]      = g_state.tempC;
  doc["trend"]     = (int)g_state.trend;
  doc["haveTrend"] = g_state.haveTrend;
  doc["forecast"]  = g_state.haveTrend ? g_state.forecast.text : "Collecting data...";
  doc["category"]  = g_state.haveTrend ? categoryShort(g_state.forecast.category) : "...";
  String out;
  serializeJson(doc, out);
  server.send(200, "application/json", out);
}

static void handleHistory() {
  JsonDocument doc;
  JsonArray arr = doc.to<JsonArray>();
  int n = historyCount();
  for (int i = 0; i < n; i++) {
    Sample s = historyGet(i);
    JsonObject o = arr.add<JsonObject>();
    o["t"]    = s.epoch;
    o["msl"]  = s.mslHpa;
    o["temp"] = s.tempC;
  }
  String out;
  serializeJson(doc, out);
  server.send(200, "application/json", out);
}

static void handleGetSettings() {
  JsonDocument doc;
  doc["altitude"] = settings.altitudeM;
  doc["tz"]       = settings.tzOffsetMin;
  doc["lat"]      = settings.lat;
  doc["lon"]      = settings.lon;
  String out;
  serializeJson(doc, out);
  server.send(200, "application/json", out);
}

static void handlePostSettings() {
  JsonDocument doc;
  if (deserializeJson(doc, server.arg("plain"))) {
    server.send(400, "application/json", "{\"ok\":false,\"err\":\"bad json\"}");
    return;
  }
  settings.altitudeM   = doc["altitude"] | settings.altitudeM;
  settings.tzOffsetMin = doc["tz"]       | settings.tzOffsetMin;
  settings.lat         = doc["lat"]      | settings.lat;
  settings.lon         = doc["lon"]      | settings.lon;
  settingsSave();
  server.send(200, "application/json", "{\"ok\":true}");
}

void webBegin() {
  server.on("/", handleRoot);
  server.on("/api/current", handleCurrent);
  server.on("/api/history", handleHistory);
  server.on("/api/settings", HTTP_GET,  handleGetSettings);
  server.on("/api/settings", HTTP_POST, handlePostSettings);
  server.begin();
}

void webLoop() { server.handleClient(); }
  • Step 3: Create a minimal web_page.h placeholder (replaced in Task 13)
#pragma once
#include <Arduino.h>
static const char INDEX_HTML[] PROGMEM = "<!doctype html><meta charset=utf-8><title>Weather Predictor</title><h1>Weather Predictor</h1><p>See <a href=/api/current>/api/current</a></p>";
  • Step 4: Wire into WeatherPredictor.ino

Add #include "web_server.h". At the end of setup() (only meaningful when connected, but harmless otherwise):

  webBegin();

In loop(), at the top:

  webLoop();
  • Step 5: Build, upload, verify API in a browser

With the board on Wi-Fi, open http://<board-ip>/api/current — expect a JSON object with time/abs/msl/temp/trend/forecast. Open /api/history — expect a JSON array (grows over time). Open /api/settings — expect altitude/tz/lat/lon. Verify a POST persists: run

curl -X POST -d "{\"altitude\":200}" http://<board-ip>/api/settings

expect {"ok":true}, then reload /api/settings and confirm altitude=200 (and it survives a reboot).

  • Step 6: Commit
git add WeatherPredictor/
git commit -m "feat: HTTP server with REST API for current/history/settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 13: Web UI (live view + SVG chart + settings form)

Replace the placeholder page with a self-contained single-page UI served from flash (no external CDNs).

Files:

  • Modify: WeatherPredictor/web_page.h (full rewrite)

Interfaces:

  • Consumes: /api/current, /api/history, /api/settings from Task 12.

  • Step 1: Rewrite web_page.h with the full UI

#pragma once
#include <Arduino.h>

static const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Weather Predictor</title>
<style>
  :root { color-scheme: light dark; }
  body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; max-width: 720px; margin-inline: auto; }
  h1 { font-size: 1.3rem; }
  .card { border: 1px solid #8884; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
  .big { font-size: 2rem; font-weight: 700; }
  .row { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
  .muted { opacity: .7; font-size: .9rem; }
  label { display: block; margin: 8px 0 2px; font-size: .9rem; }
  input { width: 100%; padding: 8px; box-sizing: border-box; border-radius: 8px; border: 1px solid #8886; }
  button { margin-top: 12px; padding: 10px 16px; border-radius: 8px; border: 0; background: #2b7; color: #fff; font-size: 1rem; }
  svg { width: 100%; height: 180px; }
  .axis { stroke: #8886; stroke-width: 1; }
  .p-line { fill: none; stroke: #2b7; stroke-width: 2; }
  .t-line { fill: none; stroke: #e83; stroke-width: 2; }
  .legend span { display: inline-block; margin-right: 16px; font-size: .85rem; }
  .sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: middle; margin-right: 4px; }
</style>
</head>
<body>
  <h1>Weather Predictor</h1>

  <div class="card">
    <div class="row">
      <div><div class="muted">Time</div><div class="big" id="time">--:--</div></div>
      <div><div class="muted">Temperature</div><div class="big" id="temp">-- C</div></div>
    </div>
    <div class="row" style="margin-top:12px">
      <div><div class="muted">Pressure (sea level)</div><div class="big"><span id="msl">----</span> <span id="trend"></span></div></div>
    </div>
    <div style="margin-top:12px"><div class="muted">Forecast</div><div id="forecast" style="font-size:1.2rem">...</div></div>
    <div class="muted" id="abs" style="margin-top:8px"></div>
  </div>

  <div class="card">
    <div class="legend">
      <span><span class="sw" style="background:#2b7"></span>Pressure (hPa)</span>
      <span><span class="sw" style="background:#e83"></span>Temperature (C)</span>
    </div>
    <svg id="chart" viewBox="0 0 320 180" preserveAspectRatio="none"></svg>
    <div class="muted" id="hint"></div>
  </div>

  <div class="card">
    <h2 style="font-size:1.05rem;margin-top:0">Settings</h2>
    <label>Altitude (m)</label><input id="s-alt" type="number" step="1">
    <label>Timezone offset (minutes from UTC)</label><input id="s-tz" type="number" step="15">
    <label>Latitude</label><input id="s-lat" type="number" step="0.0001">
    <label>Longitude</label><input id="s-lon" type="number" step="0.0001">
    <button id="save">Save</button>
    <div class="muted" id="saved"></div>
  </div>

<script>
function arrow(t){ return t>0 ? "↑" : (t<0 ? "↓" : "→"); }

async function refresh(){
  try{
    const c = await (await fetch('/api/current')).json();
    document.getElementById('time').textContent = c.time.split(' ')[1].slice(0,5);
    document.getElementById('temp').textContent = c.temp.toFixed(1)+' C';
    document.getElementById('msl').textContent  = c.msl.toFixed(0);
    document.getElementById('trend').textContent = arrow(c.trend);
    document.getElementById('forecast').textContent = c.forecast;
    document.getElementById('abs').textContent = 'Absolute: '+c.abs.toFixed(1)+' hPa | '+c.time;
  }catch(e){}
  drawChart();
}

async function drawChart(){
  const svg = document.getElementById('chart');
  let data;
  try{ data = await (await fetch('/api/history')).json(); }catch(e){ return; }
  const W=320,H=180,pad=4;
  if(!data.length){ document.getElementById('hint').textContent='Collecting data...'; svg.innerHTML=''; return; }
  const ps=data.map(d=>d.msl), ts=data.map(d=>d.temp);
  const n=data.length;
  function path(vals){
    const mn=Math.min(...vals), mx=Math.max(...vals), rng=(mx-mn)||1;
    return vals.map((v,i)=>{
      const x=pad+(W-2*pad)*(n>1?i/(n-1):0);
      const y=H-pad-(H-2*pad)*((v-mn)/rng);
      return (i?'L':'M')+x.toFixed(1)+' '+y.toFixed(1);
    }).join(' ');
  }
  svg.innerHTML =
    '<line class="axis" x1="0" y1="'+(H-1)+'" x2="'+W+'" y2="'+(H-1)+'"/>'+
    '<path class="p-line" d="'+path(ps)+'"/>'+
    '<path class="t-line" d="'+path(ts)+'"/>';
  const hrs=((data[n-1].t-data[0].t)/3600).toFixed(1);
  document.getElementById('hint').textContent=n+' samples over '+hrs+' h';
}

async function loadSettings(){
  const s = await (await fetch('/api/settings')).json();
  document.getElementById('s-alt').value = s.altitude;
  document.getElementById('s-tz').value  = s.tz;
  document.getElementById('s-lat').value = s.lat;
  document.getElementById('s-lon').value = s.lon;
}

document.getElementById('save').addEventListener('click', async ()=>{
  const body = {
    altitude: parseFloat(document.getElementById('s-alt').value),
    tz:       parseInt(document.getElementById('s-tz').value),
    lat:      parseFloat(document.getElementById('s-lat').value),
    lon:      parseFloat(document.getElementById('s-lon').value)
  };
  const r = await (await fetch('/api/settings',{method:'POST',body:JSON.stringify(body)})).json();
  document.getElementById('saved').textContent = r.ok ? 'Saved.' : 'Error saving.';
});

loadSettings();
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>
)HTML";
  • Step 2: Build, upload, verify the UI in a browser

Open http://<board-ip>/. Expect:

  • Live card: time, temperature, sea-level pressure with a trend arrow, forecast text, and absolute pressure line; values refresh every 15 s.

  • Chart card: pressure (green) and temperature (orange) lines once history exists (shows "Collecting data..." until then).

  • Settings card: fields pre-filled from the device; editing altitude and clicking Save shows "Saved.", and reloading the page (or rebooting) keeps the new value.

  • Step 3: Commit

git add WeatherPredictor/
git commit -m "feat: self-contained web UI with live view, SVG chart, settings

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Self-Review

Spec coverage (against 2026-07-17-weather-predictor-design.md):

Spec item Task
Wiring / I2C on D6/D1 1
Display (ST7735 MINI160x80) 2, 8
BMP180 read + MSL conversion 3
DS3231 time 4
Zambretti forecast + trend 5, 6
History buffer + trend 6
LittleFS settings 7
Full display layout + scheduler 8
History persistence 9
WiFiManager 10
NTP sync 11
REST API 12
Web UI + SVG chart + settings 13
English-only text all (constraint)
Defaults (150 m, UTC+7, coords) 1, 7

No spec item is unaddressed. Out-of-scope items (online providers, Cyrillic, night dimming, CSV export) are intentionally excluded per the spec.

Placeholder scan: No TBD/TODO; every code step contains complete, compilable code. The Task 12 web_page.h is an intentional minimal page, fully replaced in Task 13.

Type consistency: RtcTime, Sample, Forecast, Trend, WxCategory, AppState, and AppSettings are defined once and used with identical field names across tasks. Function names (sensorsBegin, toSeaLevelHpa, rtcNow, computeForecast, classifyTrend, historyTrendDelta, settingsSave, ntpSync, webBegin/webLoop) match between their producing task and every consuming task. tft is made non-static in Task 8 to satisfy the extern in displayRender.

Known integration notes (not blockers):

  • I2C driver begin() calls rely on the ESP8266 core reusing the pins from the explicit Wire.begin(I2C_SDA, I2C_SCL) in setup(). If Task 1's scan shows no devices, that assumption failed on your core version — re-assert Wire.begin(I2C_SDA, I2C_SCL) immediately before each driver begin().
  • The 0.96" ST7735S often needs invertDisplay(true) and may need a specific rotation/offset; Task 2 flags where to adjust using the user's already-working init.