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:
@@ -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).
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 → ~24–48 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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 "...";
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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(); }
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
bool netBegin();
|
||||
bool netConnected();
|
||||
String netIP();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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 Predictor</h1>
|
||||
<p class="lede">Reading the sky by pressure alone, the <b>Zambretti</b> way — station at
|
||||
<span id="coords">54.99°N, 82.87°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 · sea level</span>
|
||||
<span class="chip" id="chip" style="color:var(--muted)">—</span>
|
||||
</div>
|
||||
<p class="verdict" id="verdict">Reading the sky…</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">—</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">—</div><div class="s" id="deltas">—</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">—</span><span id="axT1">—</span></div>
|
||||
<p class="hint" id="hint">Collecting data…</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 · 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 · 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)+"°N, "+Number(s.lon).toFixed(2)+"°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";
|
||||
@@ -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(); }
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
void webBegin();
|
||||
void webLoop();
|
||||
Reference in New Issue
Block a user