From ffb2ac3756e0d18f69459eb899ff9586271577f2 Mon Sep 17 00:00:00 2001 From: Vitali Date: Fri, 17 Jul 2026 19:19:23 +0700 Subject: [PATCH] docs: add Weather Predictor implementation plan (13 tasks) Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-17-weather-predictor.md | 1639 +++++++++++++++++ 1 file changed, 1639 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-weather-predictor.md diff --git a/docs/superpowers/plans/2026-07-17-weather-predictor.md b/docs/superpowers/plans/2026-07-17-weather-predictor.md new file mode 100644 index 0000000..8716245 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-weather-predictor.md @@ -0,0 +1,1639 @@ +# 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 `. + +## 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`** + +```cpp +#pragma once +#include + +// ---- 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)** + +```cpp +#include +#include +#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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: sketch scaffold with I2C bus bring-up and scanner + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +void displayBegin(); +void displaySplash(const char* line1, const char* line2); +``` + +- [ ] **Step 2: Create `display_ui.cpp`** + +```cpp +#include +#include +#include +#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: + +```cpp +#include "display_ui.h" +``` +Add at the end of `setup()`: +```cpp + 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: TFT display init and splash screen + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +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`** + +```cpp +#include +#include +#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(...)`: +```cpp + if (!sensorsBegin()) Serial.println(F("BMP180 not found!")); +``` +Replace the empty `loop()` with a temporary 2 s print (removed in Task 8): +```cpp +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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: BMP180 read and sea-level pressure conversion + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +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`** + +```cpp +#include +#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()`: +```cpp + 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: +```cpp +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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: DS3231 RTC read/set with lost-power detection + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +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`** + +```cpp +#include +#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()`: +```cpp + // --- 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: Zambretti forecast with trend classification + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +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`** + +```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()`: +```cpp + // --- 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: in-RAM pressure history ring buffer with 3h trend + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +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`** + +```cpp +#include +#include +#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()`: +```cpp + // --- 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: persistent settings in LittleFS JSON + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#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`** + +```cpp +#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: + +```cpp +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** + +```cpp +#include +#include +#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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: integrate scheduler and full status display + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +bool historySave(); +bool historyLoad(); +``` +(Add after the existing declarations.) + +- [ ] **Step 2: Implement save/load in `history.cpp`** + +Add includes at the top: +```cpp +#include +``` +Append: +```cpp +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: +```cpp +static unsigned long tFlush = 0; +``` +In `setup()`, after `historyBegin();`, restore prior history: +```cpp + historyLoad(); // ignore result: empty on first boot +``` +At the end of `setup()` set `tFlush = millis();`. +In `loop()`, add: +```cpp + 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: persist pressure history to LittleFS across reboots + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +#include + +bool netBegin(); +bool netConnected(); +String netIP(); +``` + +- [ ] **Step 2: Create `net.cpp`** + +```cpp +#include +#include +#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();`: +```cpp + 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: Wi-Fi provisioning via WiFiManager captive portal + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +bool ntpSync(int tzOffsetMin); +``` + +- [ ] **Step 2: Implement in `rtc_time.cpp`** + +Add includes at the top: +```cpp +#include +#include "config.h" +``` +Append: +```cpp +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: +```cpp + 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: NTP time sync writing to DS3231 + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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`** + +```cpp +#pragma once +void webBegin(); +void webLoop(); +``` + +- [ ] **Step 2: Create `web_server.cpp`** + +```cpp +#include +#include +#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(); + int n = historyCount(); + for (int i = 0; i < n; i++) { + Sample s = historyGet(i); + JsonObject o = arr.add(); + 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)** + +```cpp +#pragma once +#include +static const char INDEX_HTML[] PROGMEM = "Weather Predictor

Weather Predictor

See /api/current

"; +``` + +- [ ] **Step 4: Wire into `WeatherPredictor.ino`** + +Add `#include "web_server.h"`. At the end of `setup()` (only meaningful when connected, but harmless otherwise): +```cpp + webBegin(); +``` +In `loop()`, at the top: +```cpp + webLoop(); +``` + +- [ ] **Step 5: Build, upload, verify API in a browser** + +With the board on Wi-Fi, open `http:///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 +```bash +curl -X POST -d "{\"altitude\":200}" http:///api/settings +``` +expect `{"ok":true}`, then reload `/api/settings` and confirm altitude=200 (and it survives a reboot). + +- [ ] **Step 6: Commit** + +```bash +git add WeatherPredictor/ +git commit -m "feat: HTTP server with REST API for current/history/settings + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### 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** + +```cpp +#pragma once +#include + +static const char INDEX_HTML[] PROGMEM = R"HTML( + + + + + +Weather Predictor + + + +

Weather Predictor

+ +
+
+
Time
--:--
+
Temperature
-- C
+
+
+
Pressure (sea level)
----
+
+
Forecast
...
+
+
+ +
+
+ Pressure (hPa) + Temperature (C) +
+ +
+
+ +
+

Settings

+ + + + + +
+
+ + + + +)HTML"; +``` + +- [ ] **Step 2: Build, upload, verify the UI in a browser** + +Open `http:///`. 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** + +```bash +git add WeatherPredictor/ +git commit -m "feat: self-contained web UI with live view, SVG chart, settings + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## 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.