Flatten so the repo root is the Arduino sketchbook; the project lives in WeatherPredictor/. Add a collection index README at the root and move the detailed project README into WeatherPredictor/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1640 lines
52 KiB
Markdown
1640 lines
52 KiB
Markdown
# 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`**
|
|
|
|
```cpp
|
|
#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)**
|
|
|
|
```cpp
|
|
#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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```cpp
|
|
#pragma once
|
|
#include <Arduino.h>
|
|
|
|
void displayBegin();
|
|
void displaySplash(const char* line1, const char* line2);
|
|
```
|
|
|
|
- [ ] **Step 2: Create `display_ui.cpp`**
|
|
|
|
```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:
|
|
|
|
```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 <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`**
|
|
|
|
```cpp
|
|
#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`**
|
|
|
|
```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(...)`:
|
|
```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 <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`**
|
|
|
|
```cpp
|
|
#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`**
|
|
|
|
```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()`:
|
|
```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 <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`**
|
|
|
|
```cpp
|
|
#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`**
|
|
|
|
```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()`:
|
|
```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 <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`**
|
|
|
|
```cpp
|
|
#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`**
|
|
|
|
```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 <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`**
|
|
|
|
```cpp
|
|
#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`**
|
|
|
|
```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()`:
|
|
```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 <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`**
|
|
|
|
```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 <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**
|
|
|
|
```bash
|
|
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`**
|
|
|
|
```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 <LittleFS.h>
|
|
```
|
|
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 <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`**
|
|
|
|
```cpp
|
|
#pragma once
|
|
#include <Arduino.h>
|
|
|
|
bool netBegin();
|
|
bool netConnected();
|
|
String netIP();
|
|
```
|
|
|
|
- [ ] **Step 2: Create `net.cpp`**
|
|
|
|
```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();`:
|
|
```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 <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`**
|
|
|
|
```cpp
|
|
bool ntpSync(int tzOffsetMin);
|
|
```
|
|
|
|
- [ ] **Step 2: Implement in `rtc_time.cpp`**
|
|
|
|
Add includes at the top:
|
|
```cpp
|
|
#include <time.h>
|
|
#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 <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`**
|
|
|
|
```cpp
|
|
#pragma once
|
|
void webBegin();
|
|
void webLoop();
|
|
```
|
|
|
|
- [ ] **Step 2: Create `web_server.cpp`**
|
|
|
|
```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)**
|
|
|
|
```cpp
|
|
#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):
|
|
```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://<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
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```cpp
|
|
#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**
|
|
|
|
```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 <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.
|