chore: make repo an Arduino collection with WeatherPredictor project

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>
This commit is contained in:
2026-07-18 11:58:33 +07:00
co-authored by Claude Opus 4.8
parent fc72f9b0bd
commit 11f57a244d
26 changed files with 120 additions and 111 deletions
+114
View File
@@ -0,0 +1,114 @@
# Weather Predictor
An autonomous **barometric weather station** built on a Wemos D1 mini (ESP8266).
It reads atmospheric pressure and temperature, keeps accurate battery-backed
time, and predicts near-term local weather entirely on-device using the
**Zambretti algorithm** — no internet forecast service required. Readings and
the forecast are shown on a small TFT and on a self-hosted web interface styled
as a barometer instrument.
## Features
- **Local Zambretti forecast** from the 3-hour sea-level pressure trend (rising / steady / falling), adjusted for season.
- **On-device TFT screen** (160×80): time, date, pressure with trend arrow, temperature, a scaled weather icon, and the station's Wi-Fi/IP.
- **Web interface** served from the device (no external CDNs, works offline):
- live barometer dial with a needle and a "set hand" ghost at the pressure 3 h ago;
- barograph of pressure & temperature with labelled axes;
- **forecast change log**;
- settings with **city presets** and a UTC-offset time-zone picker.
- **Persistence** in LittleFS: settings, pressure history, and the forecast log all survive reboots.
- **Wi-Fi provisioning** via WiFiManager (captive portal — no credentials in code) and **NTP** time sync to the DS3231.
## Hardware
| Component | Part | Bus / notes |
|-----------|------|-------------|
| MCU | Wemos D1 mini (ESP8266, 4 MB flash) | — |
| Pressure & temperature | BMP180 (GY-68) | I²C, addr `0x77` |
| Real-time clock | DS3231 (HW-022, battery-backed) | I²C, addr `0x68` |
| Display | 0.96" 80×160 TFT, ST7735S | 4-wire SPI, run landscape 160×80 |
### Wiring
The display uses hardware SPI; the two I²C modules share one bus.
| Signal | Wemos pin | GPIO |
|--------|-----------|------|
| TFT CS | D8 | 15 |
| TFT DC | D3 | 0 |
| TFT RST | D4 | 2 |
| TFT SDA (MOSI) | D7 | 13 |
| TFT SCL (SCK) | D5 | 14 |
| TFT BLK (backlight) | D2 | 4 |
| I²C SDA (BMP180 + DS3231) | D6 | 12 |
| I²C SCL (BMP180 + DS3231) | D1 | 5 |
All modules run on 3.3 V / GND. I²C is started with `Wire.begin(D6 /*SDA*/, D1 /*SCL*/)`.
## Build & flash (Arduino IDE)
1. Install the **ESP8266** board package; select board **"LOLIN(WEMOS) D1 R2 & mini"**.
2. Set **Tools → Flash Size** to a layout with a filesystem, e.g. **4MB (FS:2MB)**.
3. Install libraries via Library Manager:
*Adafruit GFX*, *Adafruit ST7735 and ST7789*, *Adafruit BMP085*, *RTClib*,
*ArduinoJson* (v7.x), *WiFiManager* (by tzapu).
`ESP8266WiFi`, `ESP8266WebServer`, `LittleFS`, `Wire`, `SPI`, `time` ship with the core.
4. Open `WeatherPredictor/WeatherPredictor.ino` and upload.
## First run
1. On first boot the device opens a Wi-Fi access point **`WeatherPredictor-Setup`**.
Join it from a phone and pick your network in the captive portal.
2. After connecting, the device syncs time over NTP and writes it to the DS3231.
3. The screen and Serial (115200) print the station's IP — open `http://<ip>/`.
4. The forecast shows **"Collecting data…"** until ~3 h of pressure history exists;
that is expected — the Zambretti method needs a trend before its first call.
Set your **altitude** in the web settings (or pick a city preset): sea-level
pressure — and therefore the forecast — depends on it (~0.12 hPa per metre).
## REST API
| Endpoint | Method | Returns |
|----------|--------|---------|
| `/` | GET | Web UI |
| `/api/current` | GET | time, pressure (abs + sea level), temperature, trend, forecast |
| `/api/history` | GET | recent pressure/temperature samples |
| `/api/forecasts` | GET | forecast change log (newest first) |
| `/api/settings` | GET / POST | altitude, time-zone offset, coordinates |
## Project structure
```
WeatherPredictor/
WeatherPredictor.ino scheduler; wires the modules together
config.h pins, addresses, intervals, defaults
state.h shared current-readings struct
sensors.* BMP180 read + sea-level conversion
rtc_time.* DS3231 read/set + NTP sync
display_ui.* ST7735 rendering + weather icons
forecast.* Zambretti forecast + trend classification
history.* pressure ring buffer + LittleFS persistence
flog.* forecast change log
app_settings.* settings struct + LittleFS JSON
net.* WiFiManager connection
web_server.* HTTP server + REST API
web_page.h web UI (HTML/CSS/JS) in PROGMEM
docs/superpowers/ design spec and implementation plan
```
## Notes & gotchas
- **BGR panel:** this 0.96" ST7735S has red/blue swapped, so colours are built
R/B-exchanged in `display_ui.cpp`. If your panel looks wrong, adjust there
(and the `invertDisplay` / `setRotation` in `displayBegin()`).
- **Restrictive networks:** on some LANs the router does not resolve external
hostnames, so NTP uses hard-coded Cloudflare/Google anycast IPs instead of
names. Such networks may also block SSH.
- **Flash wear:** history is flushed every 15 min and the forecast log only on
change, so LittleFS wear leveling keeps flash life at years/decades.
## Credits
Zambretti forecast constants and lookup tables adapted from
[G6EJD's ESP Zambretti forecaster](https://github.com/G6EJD/ESP32_Weather_Forecaster_TN061).
+103
View File
@@ -0,0 +1,103 @@
#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 "flog.h"
#include "app_settings.h"
#include "net.h"
#include "web_server.h"
#include "state.h"
AppState g_state;
static unsigned long tSample = 0;
static unsigned long tHistory = 0;
static unsigned long tFlush = 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);
flogConsider(rtcEpoch(), g_state.forecast); // log only when the forecast changes
} 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();
historyLoad(); // restore prior samples (empty on first ever boot)
Serial.printf("history restored: %d samples\n", historyCount());
flogBegin();
flogLoad(); // restore forecast change log
Serial.printf("forecast log restored: %d entries\n", flogCount());
displaySplash("WiFi", "Connect / portal");
if (netBegin()) {
Serial.printf("WiFi connected: %s\n", netIP().c_str());
if (ntpSync(settings.tzOffsetMin))
Serial.println(F("RTC synced from NTP"));
else
Serial.println(F("NTP sync failed"));
} else {
Serial.println(F("WiFi not connected - running offline"));
}
// Seed first sample immediately.
sampleNow();
historyAdd(rtcEpoch(), g_state.mslHpa, g_state.tempC);
displayRender(g_state);
webBegin();
if (netConnected()) Serial.printf("Web UI: http://%s/\n", netIP().c_str());
tSample = tHistory = tFlush = millis();
}
void loop() {
webLoop();
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);
}
if (now - tFlush >= FLUSH_INTERVAL_MS) {
tFlush = now;
historySave();
Serial.printf("history flushed: %d samples\n", historyCount());
}
}
+52
View File
@@ -0,0 +1,52 @@
#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();
}
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <Arduino.h>
struct AppSettings {
float altitudeM;
int tzOffsetMin;
float lat;
float lon;
};
extern AppSettings settings;
void settingsBegin();
bool settingsSave();
void settingsDefaults();
+37
View File
@@ -0,0 +1,37 @@
#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
// ---- Forecast change log ----
static const int FORECAST_LOG_SIZE = 24; // last N forecast changes
// ---- 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 ----
// NTP is done by hard-coded IP in rtc_time.cpp (this LAN's DNS is unreliable).
#define AP_NAME "WeatherPredictor-Setup"
+157
View File
@@ -0,0 +1,157 @@
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>
#include <math.h>
#include "config.h"
#include "display_ui.h"
#include "net.h"
// Non-static so displayRender() can reference it via extern.
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
// This 0.96" ST7735S panel is wired BGR (red/blue swapped), so build every
// colour with R and B exchanged and it displays as intended.
static uint16_t rgb(uint8_t r, uint8_t g, uint8_t b) { return tft.color565(b, g, r); }
static uint16_t C_MUTED, C_FRAME, C_LINE, C_SUN, C_CLOUD;
static uint16_t catColor(WxCategory c) {
switch (c) {
case WX_FINE: return rgb(143,190,138); // fine green
case WX_FAIR: return rgb(228,182,88); // amber
case WX_CHANGEABLE: return rgb(121,194,208); // mercury
case WX_RAIN: return rgb(92,134,214); // blue
case WX_STORM: return rgb(228,87,59); // vermilion
default: return C_MUTED;
}
}
void displayBegin() {
pinMode(TFT_BLK, OUTPUT);
digitalWrite(TFT_BLK, HIGH); // backlight on
tft.initR(INITR_MINI160x80); // 0.96" ST7735S
tft.invertDisplay(false); // this panel: no inversion
tft.setRotation(3); // landscape, 160 wide x 80 tall
C_MUTED = rgb(140,160,190);
C_FRAME = rgb(42,58,90);
C_LINE = rgb(121,194,208); // mercury cyan
C_SUN = rgb(240,200,70);
C_CLOUD = rgb(224,232,240);
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, 20);
tft.print(line1);
tft.setTextSize(1);
tft.setCursor(4, 50);
tft.print(line2);
}
// Weather icon scaled into a W x H box at (x,y).
static void drawIcon(WxCategory c, int x, int y, int W, int H) {
int cx = x + W / 2, cy = y + H / 2;
int m = (W < H ? W : H);
switch (c) {
case WX_FINE: {
int r = (int)(m * 0.30f);
tft.fillCircle(cx, cy, r, C_SUN);
for (int k = 0; k < 8; k++) {
float a = k * 0.7853982f; // 45 deg steps
int x0 = cx + (int)((r + 4) * cosf(a)), y0 = cy + (int)((r + 4) * sinf(a));
int x1 = cx + (int)((r + 11) * cosf(a)), y1 = cy + (int)((r + 11) * sinf(a));
tft.drawLine(x0, y0, x1, y1, C_SUN);
}
break;
}
case WX_FAIR:
tft.fillCircle(x + (int)(W * 0.34f), y + (int)(H * 0.34f), (int)(m * 0.24f), C_SUN);
tft.fillRoundRect(x + (int)(W * 0.12f), y + (int)(H * 0.48f),
(int)(W * 0.76f), (int)(H * 0.40f), (int)(H * 0.20f), C_CLOUD);
break;
case WX_CHANGEABLE:
tft.fillRoundRect(x + (int)(W * 0.06f), y + (int)(H * 0.28f),
(int)(W * 0.88f), (int)(H * 0.46f), (int)(H * 0.23f), C_CLOUD);
break;
case WX_RAIN: {
tft.fillRoundRect(x + (int)(W * 0.06f), y + (int)(H * 0.14f),
(int)(W * 0.88f), (int)(H * 0.40f), (int)(H * 0.20f), C_CLOUD);
int ry = y + (int)(H * 0.60f);
for (int i = 0; i < 5; i++)
tft.drawFastVLine(x + (int)(W * 0.18f) + i * (int)(W * 0.16f), ry, (int)(H * 0.26f), C_LINE);
break;
}
case WX_STORM: {
tft.fillRoundRect(x + (int)(W * 0.06f), y + (int)(H * 0.14f),
(int)(W * 0.88f), (int)(H * 0.40f), (int)(H * 0.20f), C_CLOUD);
int by = y + (int)(H * 0.58f);
tft.fillTriangle(cx, by, cx - 8, by + (int)(H * 0.30f), cx + 9, by + (int)(H * 0.22f), C_SUN);
break;
}
default:
tft.drawRoundRect(x + 2, y + (int)(H * 0.2f), W - 4, (int)(H * 0.55f), 8, C_FRAME);
tft.setTextSize(2); tft.setTextColor(C_MUTED);
tft.setCursor(cx - 6, cy - 8); tft.print("?");
break;
}
}
// Layout for landscape 160 x 80.
void displayRender(const AppState& s) {
tft.fillScreen(ST77XX_BLACK);
tft.setTextColor(ST77XX_WHITE);
char buf[24];
// Time (large), top-left
snprintf(buf, sizeof(buf), "%02u:%02u", s.now.hour, s.now.minute);
tft.setTextSize(2);
tft.setCursor(2, 2);
tft.print(buf);
// Date
tft.setTextSize(1);
snprintf(buf, sizeof(buf), "%04u-%02u-%02u", s.now.year, s.now.month, s.now.day);
tft.setCursor(2, 22);
tft.print(buf);
// Pressure + trend arrow (arrow only when rising/falling)
snprintf(buf, sizeof(buf), "%.0f hPa", s.mslHpa);
tft.setCursor(2, 34);
tft.print(buf);
if (s.trend != TREND_STEADY) {
tft.setTextColor(C_LINE);
tft.setCursor(80, 34);
tft.print(s.trend == TREND_RISING ? "^" : "v");
tft.setTextColor(ST77XX_WHITE);
}
// Temperature
snprintf(buf, sizeof(buf), "%.1f C", s.tempC);
tft.setCursor(2, 46);
tft.print(buf);
// Predicted-weather icon, top-right (fills to just above the divider)
drawIcon(s.haveTrend ? s.forecast.category : WX_UNKNOWN, 78, 2, 78, 61);
// Forecast category (colour-coded)
tft.setTextSize(1);
tft.setTextColor(s.haveTrend ? catColor(s.forecast.category) : C_MUTED);
tft.setCursor(2, 56);
tft.print(s.haveTrend ? categoryShort(s.forecast.category) : "Collecting...");
// Bottom strip: divider + Wi-Fi / IP
tft.drawFastHLine(0, 65, 160, C_FRAME);
tft.setTextSize(1);
if (netConnected()) {
tft.setTextColor(C_LINE);
tft.setCursor(2, 69);
tft.print(netIP());
} else {
tft.setTextColor(C_MUTED);
tft.setCursor(2, 69);
tft.print("WiFi: offline");
}
tft.setTextColor(ST77XX_WHITE);
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
#include "state.h"
void displayBegin();
void displaySplash(const char* line1, const char* line2);
void displayRender(const AppState& s);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
# Weather Predictor — Design Spec
**Date:** 2026-07-17
**Status:** Approved (design), pending implementation plan
## Overview
An autonomous barometric weather-prediction station built on a Wemos D1 mini
(ESP8266). It measures atmospheric pressure and temperature (BMP180), keeps
accurate time (DS3231 RTC), predicts near-term weather locally using the
**Zambretti algorithm** (pressure trend based, no internet required), and
displays current readings + forecast on a small TFT. A built-in web interface
(served from the device) shows live readings, a 24 h history chart, and
editable settings.
All on-device and web text is in **English**.
## Hardware
| Component | Model |
|-----------|-------|
| MCU | Wemos D1 mini (ESP8266, ~4 MB flash) |
| Pressure/Temp sensor | BMP180 (GY-68), I2C addr 0x77 |
| RTC | DS3231 (HW-022, no EEPROM), I2C addr 0x68, battery-backed |
| Display | 0.96" 80×160 TFT, ST7735S, 4-wire SPI, driver `INITR_MINI160x80` |
### Wiring
Display uses hardware SPI; both I2C modules share one bus (D1/D6).
| Signal | Wemos pin | GPIO |
|--------|-----------|------|
| TFT CS | D8 | 15 |
| TFT DC | D3 | 0 |
| TFT RES | D4 | 2 |
| TFT SDA (MOSI) | D7 | 13 |
| TFT SCL (SCK) | D5 | 14 |
| TFT BLK (backlight) | D2 | 4 |
| I2C SCL (BMP180 + DS3231) | D1 | 5 |
| I2C SDA (BMP180 + DS3231) | D6 | 12 |
Power: all modules on 3.3 V / GND. I2C init: `Wire.begin(D6 /*SDA*/, D1 /*SCL*/)`.
## Software Architecture
Arduino IDE project: main `.ino` sketch + per-module `.h`/`.cpp` tabs.
Each module has one clear responsibility and a small interface.
| Module | Responsibility | Key library |
|--------|----------------|-------------|
| `config.h` | Pins, I2C addresses, timing constants, defaults | — |
| `sensors` | Read BMP180 pressure + temperature | Adafruit_BMP085 |
| `rtc` | Read/set DS3231 time; NTP sync when Wi-Fi up | RTClib |
| `display` | Render the status screen | Adafruit_GFX, Adafruit_ST7735 |
| `forecast` | Zambretti prediction (pure, host-testable) | — |
| `history` | Ring buffer of samples + LittleFS persistence | LittleFS |
| `settings` | Load/save settings JSON | ArduinoJson, LittleFS |
| `wifi` | Connect via WiFiManager captive portal | WiFiManager |
| `webserver` | HTTP server + REST API + static page | ESP8266WebServer |
### Third-party libraries (Arduino Library Manager)
Adafruit_GFX, Adafruit_ST7735, Adafruit_BMP085 (works for BMP180), RTClib,
ArduinoJson, WiFiManager (tzapu). Built-in: SPI, Wire, ESP8266WiFi,
ESP8266WebServer, LittleFS, time.h (NTP/SNTP).
## Forecast Logic (Zambretti)
1. BMP180 reports **absolute** pressure → convert to **mean sea-level (MSL)**
pressure using the configured altitude (barometric formula).
2. Store MSL pressure samples in the history buffer.
3. Compute the **3-hour pressure trend** → rising / steady / falling
(threshold ≈ ±1.6 hPa over 3 h).
4. Zambretti lookup from (MSL pressure, trend, season-from-month) → one of
~26 forecast codes → mapped to a short English label
(e.g. `Sunny`, `Fine`, `Changeable`, `Rain likely`, `Stormy`) + an icon +
trend arrow (↑ / → / ↓).
5. Until ~3 h of data exist after boot, show `Collecting data…`.
Southern-hemisphere handling is not needed (fixed northern location); season
derived from RTC month.
## Display Layout (80×160, portrait)
Top→bottom: large time `HH:MM`; date; MSL pressure (hPa) with trend arrow;
temperature (°C); forecast label + icon. Backlight on D2 (dimming/night mode
is a possible later enhancement, not in initial scope).
## Data Flow
```
BMP180 ──▶ sensors ──▶ MSL convert ──▶ history (RAM ring buffer)
DS3231 ──▶ rtc │
forecast (Zambretti) ──┬──▶ display
└──▶ webserver /api
history ──(every ~15 min)──▶ LittleFS (restored on boot)
settings ◀──▶ LittleFS (edited via web)
```
- Sensor sampling: every ~1 min (display/current values).
- History granularity: one stored sample every ~5 min → ~2448 h in RAM.
- Flash flush: every ~15 min (and buffer capped) to limit flash wear.
## Web Interface
Single page served from the device; **no external CDNs** (works even if the
client has no internet). Vanilla JS + a small self-written inline **SVG chart**.
Pages/sections:
1. **Live** — current time, MSL pressure + trend, temperature, forecast.
2. **History** — 24 h pressure + temperature chart.
3. **Settings** — altitude (m, default 150), timezone/UTC offset, default
coordinates (54.9870, 82.8730) shown; editable and persisted to LittleFS.
REST API:
- `GET /api/current` → time, pressure (abs + MSL), temperature, trend, forecast.
- `GET /api/history` → array of recent samples for the chart.
- `GET /api/settings` / `POST /api/settings` → read/update settings.
## Storage (LittleFS)
- `/settings.json` — altitude, timezone offset, coordinates.
- `/history.dat` (or `.json`) — periodic snapshot of the sample ring buffer,
reloaded on boot so the trend survives restarts.
## Wi-Fi & Time
- **WiFiManager**: on first boot (or no known network) the device opens a
captive-portal AP so Wi-Fi is configured from a phone — no reflashing.
- **NTP**: when Wi-Fi is connected, sync time and write it to the DS3231.
DS3231 (battery-backed) keeps accurate time when offline.
- Default location for timezone context: 54.9870 N, 82.8730 E (Novosibirsk,
UTC+7), altitude 150 m — all editable via web.
## Defaults Summary
| Setting | Default | Editable in web |
|---------|---------|-----------------|
| Altitude | 150 m | yes |
| Coordinates | 54.9870, 82.8730 | yes |
| Timezone | UTC+7 | yes |
| Sensor sample interval | ~60 s | no (constant) |
| History sample interval | ~5 min | no (constant) |
| Flash flush interval | ~15 min | no (constant) |
## Testing
Manual (Arduino IDE, no automated harness):
1. **Sensors** — Serial print of BMP180 pressure/temp; sanity-check values.
2. **RTC** — set time, power-cycle, verify time held by battery; verify NTP
sync updates DS3231.
3. **MSL conversion** — verify computed sea-level pressure is plausible for
150 m (abs ≈ MSL ~18 hPa).
4. **Trend/forecast** — inject synthetic pressure series via Serial or by
editing the buffer; confirm rising/steady/falling classification and
Zambretti label mapping against known reference values.
5. **Display** — verify layout, no overflow, correct icons/arrows.
6. **Web** — load page over LAN, confirm live values, chart renders, settings
save and persist across reboot.
7. **Persistence** — reboot; confirm settings and history restored from flash.
## Out of Scope (initial)
- Online forecast providers (variant B/C hybrid).
- Cyrillic display fonts (English only).
- Backlight night dimming, CSV/JSON export (possible later enhancements).
+62
View File
@@ -0,0 +1,62 @@
#include <LittleFS.h>
#include "config.h"
#include "flog.h"
static FcastEntry s_buf[FORECAST_LOG_SIZE];
static int s_head = 0;
static int s_count = 0;
static char s_lastLetter = 0; // last logged letter, to detect changes
void flogBegin() { s_head = 0; s_count = 0; s_lastLetter = 0; }
static void push(uint32_t ep, char letter, uint8_t cat) {
s_buf[s_head] = FcastEntry{ ep, letter, cat };
s_head = (s_head + 1) % FORECAST_LOG_SIZE;
if (s_count < FORECAST_LOG_SIZE) s_count++;
}
void flogConsider(uint32_t epoch, const Forecast& f) {
if (f.letter == s_lastLetter) return; // no change -> nothing to log
s_lastLetter = f.letter;
push(epoch, f.letter, (uint8_t)f.category);
flogSave();
}
int flogCount() { return s_count; }
FcastEntry flogGet(int i) {
int start = (s_head - s_count + FORECAST_LOG_SIZE) % FORECAST_LOG_SIZE;
return s_buf[(start + i) % FORECAST_LOG_SIZE];
}
static const char* FPATH = "/forecasts.dat";
bool flogSave() {
File f = LittleFS.open(FPATH, "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++) {
FcastEntry e = flogGet(i);
f.write((const uint8_t*)&e, sizeof(e));
}
f.close();
return true;
}
bool flogLoad() {
File f = LittleFS.open(FPATH, "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 > FORECAST_LOG_SIZE) { f.close(); return false; }
s_head = 0; s_count = 0;
for (int i = 0; i < n; i++) {
FcastEntry e;
if (f.read((uint8_t*)&e, sizeof(e)) != sizeof(e)) break;
push(e.epoch, e.letter, e.cat);
}
f.close();
if (s_count > 0) s_lastLetter = flogGet(s_count - 1).letter; // avoid re-logging on boot
return true;
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <Arduino.h>
#include "forecast.h"
struct FcastEntry {
uint32_t epoch;
char letter; // Zambretti 'A'..'Z'
uint8_t cat; // WxCategory
};
void flogBegin();
void flogConsider(uint32_t epoch, const Forecast& f); // logs only when it changes
int flogCount();
FcastEntry flogGet(int i); // 0 = oldest
bool flogSave();
bool flogLoad();
+105
View File
@@ -0,0 +1,105 @@
#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* forecastTextForLetter(char letter) {
if (letter < 'A' || letter > 'Z') return "";
return ZTEXT[letter - 'A'];
}
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 "...";
}
}
+16
View File
@@ -0,0 +1,16 @@
#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);
const char* forecastTextForLetter(char letter); // full Zambretti phrase for 'A'..'Z'
+76
View File
@@ -0,0 +1,76 @@
#include <LittleFS.h>
#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();
if (latest.epoch < 10800UL) return false;
uint32_t target = latest.epoch - 10800UL; // 3 h earlier
// Pick the newest sample at or before `target`. Scan all samples (do not
// assume epochs are strictly increasing: an NTP correction can shift them).
int idx = -1;
uint32_t bestEpoch = 0;
for (int i = 0; i < s_count; i++) {
Sample s = historyGet(i);
if (s.epoch <= target && s.epoch >= bestEpoch) { bestEpoch = s.epoch; idx = i; }
}
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;
}
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;
}
+17
View File
@@ -0,0 +1,17 @@
#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);
bool historySave();
bool historyLoad();
+14
View File
@@ -0,0 +1,14 @@
#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(); }
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include <Arduino.h>
bool netBegin();
bool netConnected();
String netIP();
+42
View File
@@ -0,0 +1,42 @@
#include <RTClib.h>
#include <time.h>
#include <ESP8266WiFi.h>
#include "config.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();
}
bool ntpSync(int tzOffsetMin) {
// NTP servers by IP to bypass DNS (this network doesn't resolve names reliably).
// Cloudflare (162.159.200.123) and Google (216.239.35.0/.4) are stable anycast.
configTime(tzOffsetMin * 60, 0, "162.159.200.123", "216.239.35.0", "216.239.35.4");
time_t now = time(nullptr);
int tries = 0;
while (now < 1700000000 && tries < 120) { // up to ~30 s
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;
}
+14
View File
@@ -0,0 +1,14 @@
#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();
bool ntpSync(int tzOffsetMin); // true if synced and RTC updated
+22
View File
@@ -0,0 +1,22 @@
#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);
}
+7
View File
@@ -0,0 +1,7 @@
#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
+15
View File
@@ -0,0 +1,15 @@
#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;
+512
View File
@@ -0,0 +1,512 @@
#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{
--ink:#0F1A2E; --ink2:#0B1322; --surface:#182642; --line:#2A3A5A;
--enamel:#F1ECE0; --brass:#C6A15B; --brass-dim:#8C7538;
--text:#EAF0F8; --muted:#93A3BE;
--storm:#E4573B; --rain:#5C86D6; --change:#79C2D0; --fair:#E4B658; --fine:#8FBE8A;
--serif:"Palatino Linotype",Palatino,"Book Antiqua",Georgia,"Times New Roman",serif;
--sans:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,"Cascadia Mono","Segoe UI Mono",Consolas,Menlo,monospace;
}
*{box-sizing:border-box}
body{
margin:0; background:
radial-gradient(120% 80% at 50% -10%, #1B2B49 0%, var(--ink) 55%, var(--ink2) 100%) fixed;
color:var(--text); font-family:var(--sans); line-height:1.5;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:760px; margin:0 auto; padding:28px 20px 48px}
/* Masthead */
.eyebrow{
font-size:.72rem; letter-spacing:.28em; text-transform:uppercase;
color:var(--brass); display:flex; align-items:center; gap:10px; margin:0 0 10px;
}
.eyebrow::before{content:""; width:26px; height:1px; background:var(--brass-dim)}
h1{
font-family:var(--serif); font-weight:600; font-size:clamp(2.1rem,7vw,3.1rem);
letter-spacing:.01em; margin:0; line-height:1.02;
}
.lede{color:var(--muted); font-size:.92rem; margin:8px 0 0}
.lede b{color:var(--text); font-weight:600}
.panel{
background:linear-gradient(180deg, var(--surface), #14203a);
border:1px solid var(--line); border-radius:16px; margin-top:22px;
}
.panel-pad{padding:22px}
.rule{height:1px; background:var(--line); border:0; margin:0}
/* Dial hero */
.dial{padding:20px 16px 6px; text-align:center}
svg{display:block; width:100%; height:auto}
#gauge{max-width:420px; margin:0 auto}
.needle{transition:transform .9s cubic-bezier(.2,.8,.2,1)}
.sethand{transition:transform .9s cubic-bezier(.2,.8,.2,1)}
.readout{
display:flex; align-items:baseline; justify-content:center; gap:12px;
margin-top:2px; flex-wrap:wrap;
}
.pressure{
font-family:var(--mono); font-variant-numeric:tabular-nums;
font-size:clamp(2.6rem,11vw,3.7rem); font-weight:600; letter-spacing:-.02em; color:var(--enamel);
}
.unit{font-family:var(--sans); font-size:.9rem; letter-spacing:.22em; text-transform:uppercase; color:var(--muted)}
.chip{
font-size:.74rem; letter-spacing:.14em; text-transform:uppercase; font-weight:600;
padding:5px 11px; border-radius:999px; border:1px solid currentColor; white-space:nowrap;
}
.verdict{
font-family:var(--serif); font-size:clamp(1.35rem,4.6vw,1.7rem); font-weight:600;
margin:16px 0 4px; padding:0 12px; color:var(--enamel);
}
.verdict-sub{color:var(--muted); font-size:.86rem; margin:0 0 20px}
/* Stats */
.stats{display:grid; grid-template-columns:repeat(3,1fr)}
.stat{padding:18px; text-align:left; border-left:1px solid var(--line)}
.stat:first-child{border-left:0}
.stat .k{font-size:.68rem; letter-spacing:.2em; text-transform:uppercase; color:var(--muted)}
.stat .v{font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:1.7rem; font-weight:600; color:var(--enamel); margin-top:6px; line-height:1}
.stat .s{font-size:.78rem; color:var(--muted); margin-top:5px}
@media(max-width:520px){
.stats{grid-template-columns:1fr 1fr}
.stat:nth-child(3){grid-column:1 / -1; border-left:0; border-top:1px solid var(--line)}
}
/* Barograph */
.head{display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:14px}
.head h2{font-family:var(--serif); font-weight:600; font-size:1.15rem; margin:0}
.legend{display:flex; gap:16px; font-size:.76rem; color:var(--muted)}
.legend i{display:inline-block; width:20px; height:2px; vertical-align:middle; margin-right:6px}
.baro{position:relative; padding:0 46px}
#chart{width:100%; height:200px; display:block}
.ax{position:absolute; top:0; height:200px; display:flex; flex-direction:column;
justify-content:space-between; font-family:var(--mono); font-variant-numeric:tabular-nums;
font-size:.7rem; pointer-events:none}
.ax-l{left:0; align-items:flex-start; color:var(--change)}
.ax-r{right:0; align-items:flex-end; color:var(--fair)}
.axx{display:flex; justify-content:space-between; padding:0 46px; margin-top:6px;
font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:.7rem; color:var(--muted)}
.hint{color:var(--muted); font-size:.78rem; margin:10px 0 0}
.cap{color:var(--muted); font-size:.74rem; letter-spacing:.06em}
/* Forecast log */
.log{list-style:none; margin:2px 0 0; padding:0}
.log li{display:flex; align-items:center; gap:13px; padding:12px 0; border-top:1px solid var(--line)}
.log li:first-child{border-top:0}
.log .dot{width:9px; height:9px; border-radius:50%; flex:0 0 auto}
.log .lt{flex:1; min-width:0}
.log .lt b{display:block; font-family:var(--serif); font-weight:600; color:var(--enamel); font-size:1.02rem; line-height:1.25}
.log .lc{font-size:.66rem; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); margin-top:2px}
.log time{font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:.76rem; color:var(--muted); white-space:nowrap; flex:0 0 auto}
.log .empty{color:var(--muted); padding:6px 0}
/* Settings */
details.set summary{
cursor:pointer; list-style:none; padding:18px 22px; display:flex; align-items:center;
justify-content:space-between; font-family:var(--serif); font-size:1.1rem; font-weight:600;
}
details.set summary::-webkit-details-marker{display:none}
details.set summary .caret{color:var(--brass); font-size:.8rem; letter-spacing:.15em; text-transform:uppercase}
.form{padding:4px 22px 22px}
.grid2{display:grid; grid-template-columns:1fr 1fr; gap:14px}
@media(max-width:520px){.grid2{grid-template-columns:1fr}}
label{display:block; font-size:.72rem; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); margin:0 0 6px}
input,select{
width:100%; padding:11px 12px; background:var(--ink2); color:var(--text);
border:1px solid var(--line); border-radius:10px; font-family:var(--mono); font-size:1rem;
}
select{appearance:none; cursor:pointer}
input:focus,select:focus{outline:none; border-color:var(--brass); box-shadow:0 0 0 3px rgba(198,161,91,.18)}
.actions{display:flex; align-items:center; gap:16px; margin-top:18px}
button{
padding:11px 22px; border:0; border-radius:10px; cursor:pointer;
background:var(--brass); color:#221a06; font-weight:700; font-size:.95rem; letter-spacing:.02em;
font-family:var(--sans);
}
button:hover{filter:brightness(1.06)}
button:focus-visible{outline:2px solid var(--enamel); outline-offset:2px}
.saved{font-size:.82rem; color:var(--fine)}
footer{color:var(--muted); font-size:.74rem; text-align:center; margin-top:26px; letter-spacing:.04em}
@media(prefers-reduced-motion:reduce){.needle,.sethand{transition:none}}
</style>
</head>
<body>
<div class="wrap">
<header>
<p class="eyebrow">Barometric Forecaster</p>
<h1>Weather&nbsp;Predictor</h1>
<p class="lede">Reading the sky by pressure alone, the <b>Zambretti</b> way station at
<span id="coords">54.99&deg;N, 82.87&deg;E</span>.</p>
</header>
<!-- Barometer dial -->
<section class="panel dial">
<svg id="gauge" viewBox="0 0 400 232" role="img" aria-label="Barometer dial"></svg>
<div class="readout">
<span class="pressure"><span id="msl">----</span></span>
<span class="unit">hPa&nbsp;·&nbsp;sea level</span>
<span class="chip" id="chip" style="color:var(--muted)">&mdash;</span>
</div>
<p class="verdict" id="verdict">Reading the sky&hellip;</p>
<p class="verdict-sub" id="verdict-sub">The forecaster needs about three hours of pressure history before its first call.</p>
</section>
<!-- Stats -->
<section class="panel stats">
<div class="stat"><div class="k">Local time</div><div class="v" id="time">--:--</div><div class="s" id="date">&mdash;</div></div>
<div class="stat"><div class="k">Temperature</div><div class="v" id="temp">--</div><div class="s">degrees celsius</div></div>
<div class="stat"><div class="k">3-hour move</div><div class="v" id="delta">&mdash;</div><div class="s" id="deltas">&mdash;</div></div>
</section>
<!-- Barograph -->
<section class="panel panel-pad">
<div class="head">
<h2>Barograph</h2>
<div class="legend">
<span><i style="background:var(--change)"></i>Pressure</span>
<span><i style="background:var(--fair)"></i>Temperature</span>
</div>
</div>
<div class="baro">
<div class="ax ax-l" id="axL"></div>
<svg id="chart" viewBox="0 0 640 200" preserveAspectRatio="none" aria-label="Pressure and temperature history"></svg>
<div class="ax ax-r" id="axR"></div>
</div>
<div class="axx"><span id="axT0">&mdash;</span><span id="axT1">&mdash;</span></div>
<p class="hint" id="hint">Collecting data&hellip;</p>
</section>
<!-- Forecast log -->
<section class="panel panel-pad">
<div class="head">
<h2>Forecast log</h2>
<span class="cap" id="log-cap"></span>
</div>
<ol class="log" id="log"></ol>
</section>
<!-- Settings -->
<details class="panel set">
<summary>Station settings <span class="caret">Adjust</span></summary>
<hr class="rule">
<div class="form">
<div style="margin-top:18px">
<label>City preset</label>
<select id="s-city"></select>
</div>
<div class="grid2" style="margin-top:14px">
<div><label>Altitude &middot; metres</label><input id="s-alt" type="number" step="1"></div>
<div><label>Time zone</label><select id="s-tz"></select></div>
<div><label>Latitude</label><input id="s-lat" type="number" step="0.0001"></div>
<div><label>Longitude</label><input id="s-lon" type="number" step="0.0001"></div>
</div>
<div class="actions">
<button id="save">Save settings</button>
<span class="saved" id="saved"></span>
</div>
</div>
</details>
<footer>Served from the station &middot; works without internet</footer>
</div>
<script>
"use strict";
var CX=200, CY=200, R=150, PMIN=960, PMAX=1060;
var ZONES=[
{from:960, to:985, label:"STORMY", color:"#E4573B"},
{from:985, to:1000, label:"RAIN", color:"#5C86D6"},
{from:1000,to:1015, label:"CHANGE", color:"#79C2D0"},
{from:1015,to:1035, label:"FAIR", color:"#E4B658"},
{from:1035,to:1060, label:"VERY DRY", color:"#8FBE8A"}
];
var CATCOLOR={Fine:"#8FBE8A",Fair:"#E4B658",Changeable:"#79C2D0",Rain:"#5C86D6",Storm:"#E4573B"};
function clamp(v,a,b){return v<a?a:(v>b?b:v);}
// pressure -> screen angle (deg): 960=180 (left), 1060=0 (right)
function angleOf(p){return 180 - (clamp(p,PMIN,PMAX)-PMIN)/(PMAX-PMIN)*180;}
function pol(r,deg){var a=deg*Math.PI/180; return [CX+r*Math.cos(a), CY-r*Math.sin(a)];}
function arcPts(r,pFrom,pTo){
var a0=angleOf(pFrom), a1=angleOf(pTo), d=(a1<a0?-1:1), s="";
for(var a=a0; (d<0? a>=a1 : a<=a1); a+=d*2){ var q=pol(r,a); s+=(s?"L":"M")+q[0].toFixed(1)+" "+q[1].toFixed(1); }
var e=pol(r,a1); s+="L"+e[0].toFixed(1)+" "+e[1].toFixed(1);
return s;
}
function el(tag,attrs,txt){
var n=document.createElementNS("http://www.w3.org/2000/svg",tag);
for(var k in attrs) n.setAttribute(k,attrs[k]);
if(txt!=null) n.textContent=txt;
return n;
}
// Build the static dial once.
function buildGauge(){
var g=document.getElementById("gauge");
// track
g.appendChild(el("path",{d:arcPts(R,PMIN,PMAX),fill:"none",stroke:"#2A3A5A","stroke-width":14,"stroke-linecap":"round"}));
// zone arcs (dim) + boundary ticks + labels
ZONES.forEach(function(z){
g.appendChild(el("path",{id:"zone-"+z.label,d:arcPts(R,z.from,z.to),fill:"none",stroke:z.color,"stroke-width":14,opacity:.28}));
var mid=(z.from+z.to)/2, lp=pol(R+21,angleOf(mid));
var t=el("text",{id:"lab-"+z.label,x:lp[0].toFixed(1),y:lp[1].toFixed(1),fill:"#93A3BE","font-size":11,"font-family":"var(--sans)","letter-spacing":"1.5","text-anchor":"middle","dominant-baseline":"middle"},z.label);
g.appendChild(t);
});
// numeric ticks
[960,980,1000,1020,1040,1060].forEach(function(p){
var o=pol(R-8,angleOf(p)), i=pol(R-18,angleOf(p));
g.appendChild(el("line",{x1:o[0].toFixed(1),y1:o[1].toFixed(1),x2:i[0].toFixed(1),y2:i[1].toFixed(1),stroke:"#4A5B7C","stroke-width":1.5}));
var np=pol(R-30,angleOf(p));
g.appendChild(el("text",{x:np[0].toFixed(1),y:np[1].toFixed(1),fill:"#6B7C9C","font-size":9,"font-family":"var(--mono)","text-anchor":"middle","dominant-baseline":"middle"},p));
});
// set-hand (ghost, hidden until data) — points up at rot 0
var sh=el("g",{id:"sethand",class:"sethand",transform:"rotate(0 "+CX+" "+CY+")",opacity:0});
sh.appendChild(el("line",{x1:CX,y1:CY,x2:CX,y2:CY-(R-22),stroke:"#93A3BE","stroke-width":2,"stroke-dasharray":"3 4"}));
sh.appendChild(el("circle",{cx:CX,cy:CY-(R-22),r:3.5,fill:"none",stroke:"#93A3BE","stroke-width":1.5}));
g.appendChild(sh);
// live needle — points up at rot 0
var nd=el("g",{id:"needle",class:"needle",transform:"rotate(0 "+CX+" "+CY+")"});
nd.appendChild(el("line",{x1:CX,y1:CY+14,x2:CX,y2:CY-(R-12),stroke:"#F1ECE0","stroke-width":3,"stroke-linecap":"round"}));
nd.appendChild(el("polygon",{points:CX+","+(CY-(R-2))+" "+(CX-5)+","+(CY-(R-16))+" "+(CX+5)+","+(CY-(R-16)),fill:"#F1ECE0"}));
g.appendChild(nd);
// hub
g.appendChild(el("circle",{cx:CX,cy:CY,r:10,fill:"#182642",stroke:"#C6A15B","stroke-width":2}));
g.appendChild(el("circle",{cx:CX,cy:CY,r:3.5,fill:"#C6A15B"}));
}
function zoneOf(p){for(var i=0;i<ZONES.length;i++){if(p>=ZONES[i].from&&p<ZONES[i].to)return ZONES[i];}return ZONES[p>=PMAX?ZONES.length-1:0];}
function setNeedle(id,p){ // rotate so up(rot0)=1010
var rot=90-angleOf(p);
document.getElementById(id).setAttribute("transform","rotate("+rot.toFixed(1)+" "+CX+" "+CY+")");
}
function highlightZone(p){
ZONES.forEach(function(z){
var on=(p>=z.from&&p<z.to);
document.getElementById("zone-"+z.label).setAttribute("opacity",on?1:.28);
var lab=document.getElementById("lab-"+z.label);
lab.setAttribute("fill",on?"#F1ECE0":"#93A3BE");
lab.setAttribute("font-weight",on?"700":"400");
});
}
function fmtSigned(x,digits){return (x>=0?"+":"")+x.toFixed(digits==null?1:digits);}
var lastHistory=[];
async function refresh(){
try{
var c=await (await fetch("/api/current")).json();
// pressure + needle + zone
var msl=c.msl;
document.getElementById("msl").textContent=msl.toFixed(0);
setNeedle("needle",msl);
highlightZone(msl);
// time
var parts=(c.time||"").split(" ");
document.getElementById("date").textContent=parts[0]||"";
document.getElementById("time").textContent=(parts[1]||"").slice(0,5);
// temp
document.getElementById("temp").textContent=c.temp.toFixed(1);
// verdict
var v=document.getElementById("verdict"), vs=document.getElementById("verdict-sub"), chip=document.getElementById("chip");
if(c.haveTrend){
v.textContent=c.forecast;
var col=CATCOLOR[c.category]||"#93A3BE";
chip.textContent=c.category; chip.style.color=col;
var word=c.trend>0?"pressure rising":(c.trend<0?"pressure falling":"pressure steady");
vs.textContent="Zambretti reading — "+word+" over the last three hours.";
}else{
v.textContent="Reading the sky…";
chip.textContent="warming up"; chip.style.color="#93A3BE";
vs.textContent="The forecaster needs about three hours of pressure history before its first call.";
}
document.getElementById("chip").setAttribute("aria-label",c.category||"");
// absolute already implied; keep footer clean
}catch(e){}
await drawChart();
await drawLog();
}
function fmtTime(ep){var d=new Date(ep*1000);function p(x){return(x<10?"0":"")+x;}return p(d.getUTCHours())+":"+p(d.getUTCMinutes());}
function fmtStamp(ep){var d=new Date(ep*1000);function p(x){return(x<10?"0":"")+x;}return p(d.getUTCMonth()+1)+"-"+p(d.getUTCDate())+" "+p(d.getUTCHours())+":"+p(d.getUTCMinutes());}
async function drawLog(){
var data;
try{ data=await (await fetch("/api/forecasts")).json(); }catch(e){ return; }
var ol=document.getElementById("log"), cap=document.getElementById("log-cap");
ol.innerHTML="";
if(!data.length){
var li=document.createElement("li"); li.className="empty";
li.textContent="No forecasts yet — the first call appears after about three hours.";
ol.appendChild(li); cap.textContent=""; return;
}
data.forEach(function(e){
var li=document.createElement("li");
var dot=document.createElement("span"); dot.className="dot";
dot.style.background=CATCOLOR[e.cat]||"#93A3BE"; li.appendChild(dot);
var lt=document.createElement("div"); lt.className="lt";
var b=document.createElement("b"); b.textContent=e.text; lt.appendChild(b);
var lc=document.createElement("div"); lc.className="lc"; lc.textContent=e.cat; lt.appendChild(lc);
li.appendChild(lt);
var tm=document.createElement("time"); tm.textContent=fmtStamp(e.t); li.appendChild(tm);
ol.appendChild(li);
});
cap.textContent=data.length+(data.length>1?" changes":" change");
}
function fillAxis(id,mx,mn,deg){
var box=document.getElementById(id); box.innerHTML="";
[mx,(mx+mn)/2,mn].forEach(function(v){
var s=document.createElement("span");
s.textContent=deg?(Math.round(v)+"°"):Math.round(v);
box.appendChild(s);
});
}
async function drawChart(){
var data;
try{ data=await (await fetch("/api/history")).json(); }catch(e){ return; }
lastHistory=data||[];
var svg=document.getElementById("chart"), W=640,H=200,pad=6;
while(svg.firstChild) svg.removeChild(svg.firstChild);
if(!data.length){
document.getElementById("hint").textContent="Collecting data…";
document.getElementById("axL").innerHTML=""; document.getElementById("axR").innerHTML="";
document.getElementById("axT0").textContent=""; document.getElementById("axT1").textContent="";
updateSetHand(); return;
}
var ps=data.map(function(d){return d.msl;}), ts=data.map(function(d){return d.temp;}), n=data.length;
var pmn=Math.min.apply(null,ps), pmx=Math.max.apply(null,ps);
var tmn=Math.min.apply(null,ts), tmx=Math.max.apply(null,ts);
[0,0.5,1].forEach(function(f){var y=(pad+(H-2*pad)*f).toFixed(1);
svg.appendChild(el("line",{x1:0,y1:y,x2:W,y2:y,stroke:"#22314F","stroke-width":1}));});
function line(vals,mn,mx,color,fill){
var rng=(mx-mn)||1,d="";
for(var i=0;i<n;i++){var x=pad+(W-2*pad)*(n>1?i/(n-1):0);
var y=H-pad-(H-2*pad)*((vals[i]-mn)/rng); d+=(i?"L":"M")+x.toFixed(1)+" "+y.toFixed(1);}
if(fill){var area=d+"L"+(W-pad)+" "+(H-pad)+"L"+pad+" "+(H-pad)+"Z";
svg.appendChild(el("path",{d:area,fill:color,opacity:.10}));}
svg.appendChild(el("path",{d:d,fill:"none",stroke:color,"stroke-width":2,"stroke-linejoin":"round"}));
}
line(ps,pmn,pmx,"#79C2D0",true);
line(ts,tmn,tmx,"#E4B658",false);
fillAxis("axL",pmx,pmn,false);
fillAxis("axR",tmx,tmn,true);
document.getElementById("axT0").textContent=fmtTime(data[0].t);
document.getElementById("axT1").textContent=fmtTime(data[n-1].t);
var hrs=((data[n-1].t-data[0].t)/3600);
document.getElementById("hint").textContent=n+" readings over "+hrs.toFixed(1)+" h · pressure in hPa, temperature in °C";
updateSetHand();
}
// Ghost "set hand": pressure ~3 h ago, and the 3-hour move readout.
function updateSetHand(){
var sh=document.getElementById("sethand");
var d=lastHistory;
if(d.length<2){ sh.setAttribute("opacity",0); return; }
var latest=d[d.length-1], target=latest.t-10800, past=null;
for(var i=0;i<d.length;i++){ if(d[i].t<=target) past=d[i]; }
if(!past || (latest.t-past.t)<9000){ sh.setAttribute("opacity",0);
document.getElementById("delta").textContent="";
document.getElementById("deltas").textContent="need 3 h of data"; return; }
setNeedle("sethand",past.msl);
sh.setAttribute("opacity",.7);
var diff=latest.msl-past.msl;
document.getElementById("delta").textContent=fmtSigned(diff,1);
var word=diff>1.6?"rising":(diff<-1.6?"falling":"steady");
document.getElementById("deltas").textContent="hPa · "+word;
}
function tzLabel(min){var s=min<0?"-":"+";var a=Math.abs(min);var h=Math.floor(a/60);var m=a%60;return "UTC"+s+h+(m?(":"+(m<10?"0":"")+m):"");}
function buildTz(){var sel=document.getElementById("s-tz");for(var m=-720;m<=840;m+=30){var o=document.createElement("option");o.value=m;o.textContent=tzLabel(m);sel.appendChild(o);}}
// City presets: approximate average elevation (m), coordinates and UTC offset (min).
var CITIES=[
{n:"Astana", alt:347, lat:51.1694, lon:71.4491, tz:300},
{n:"Karaganda", alt:553, lat:49.8047, lon:73.1094, tz:300},
{n:"Almaty", alt:848, lat:43.2220, lon:76.8512, tz:300},
{n:"Bishkek", alt:800, lat:42.8746, lon:74.5698, tz:360},
{n:"Tashkent", alt:455, lat:41.2995, lon:69.2401, tz:300},
{n:"Yekaterinburg", alt:237, lat:56.8389, lon:60.6057, tz:300},
{n:"Omsk", alt:87, lat:54.9885, lon:73.3242, tz:360},
{n:"Novosibirsk", alt:150, lat:55.0084, lon:82.9357, tz:420},
{n:"Krasnoyarsk", alt:137, lat:56.0153, lon:92.8932, tz:420},
{n:"Moscow", alt:156, lat:55.7558, lon:37.6173, tz:180},
{n:"Saint Petersburg",alt:3, lat:59.9311, lon:30.3609, tz:180}
];
function setTz(min){
var tz=document.getElementById("s-tz"); tz.value=String(min);
if(parseInt(tz.value,10)!==min){var o=document.createElement("option");o.value=min;o.textContent=tzLabel(min);tz.appendChild(o);tz.value=String(min);}
}
function buildCities(){
var sel=document.getElementById("s-city");
var o0=document.createElement("option"); o0.value="-1"; o0.textContent="Custom / manual entry"; sel.appendChild(o0);
CITIES.forEach(function(c,i){
var o=document.createElement("option"); o.value=i;
o.textContent=c.n+" · "+c.alt+" m · "+tzLabel(c.tz); sel.appendChild(o);
});
sel.addEventListener("change",function(){
var i=parseInt(sel.value,10); if(i<0) return;
var c=CITIES[i];
document.getElementById("s-alt").value=c.alt;
document.getElementById("s-lat").value=c.lat;
document.getElementById("s-lon").value=c.lon;
setTz(c.tz);
});
}
async function loadSettings(){
try{
var s=await (await fetch("/api/settings")).json();
document.getElementById("s-alt").value=s.altitude;
setTz(s.tz);
document.getElementById("s-lat").value=s.lat;
document.getElementById("s-lon").value=s.lon;
document.getElementById("coords").innerHTML=Number(s.lat).toFixed(2)+"&deg;N, "+Number(s.lon).toFixed(2)+"&deg;E";
// Auto-select a matching city preset, else "Custom".
var ci=-1;
for(var k=0;k<CITIES.length;k++){ if(Math.abs(CITIES[k].lat-s.lat)<0.02 && Math.abs(CITIES[k].lon-s.lon)<0.02){ ci=k; break; } }
document.getElementById("s-city").value=String(ci);
}catch(e){}
}
document.getElementById("save").addEventListener("click", async function(){
var 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)
};
var msg=document.getElementById("saved");
try{
var r=await (await fetch("/api/settings",{method:"POST",body:JSON.stringify(body)})).json();
msg.textContent=r.ok?"Saved.":"Could not save.";
msg.style.color=r.ok?"var(--fine)":"var(--storm)";
if(r.ok){ loadSettings(); refresh(); }
}catch(e){ msg.textContent="Could not reach the station."; msg.style.color="var(--storm)"; }
setTimeout(function(){msg.textContent="";},4000);
});
buildGauge();
buildTz();
buildCities();
loadSettings();
refresh();
setInterval(refresh,15000);
</script>
</body>
</html>
)HTML";
+102
View File
@@ -0,0 +1,102 @@
#include <ESP8266WebServer.h>
#include <ArduinoJson.h>
#include "state.h"
#include "app_settings.h"
#include "history.h"
#include "flog.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 handleForecasts() {
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
int n = flogCount();
for (int i = n - 1; i >= 0; i--) { // newest first
FcastEntry e = flogGet(i);
JsonObject o = arr.add<JsonObject>();
o["t"] = e.epoch;
o["cat"] = categoryShort((WxCategory)e.cat);
o["text"] = forecastTextForLetter(e.letter);
}
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/forecasts", handleForecasts);
server.on("/api/settings", HTTP_GET, handleGetSettings);
server.on("/api/settings", HTTP_POST, handlePostSettings);
server.begin();
}
void webLoop() { server.handleClient(); }
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void webBegin();
void webLoop();