Compare commits

...
13 Commits
Author SHA1 Message Date
oskarvitaliiandClaude Opus 4.8 1e923249f6 docs: add web UI screenshot to project README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:20:36 +07:00
oskarvitaliiandClaude Opus 4.8 11f57a244d 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>
2026-07-18 11:58:33 +07:00
oskarvitaliiandClaude Opus 4.8 fc72f9b0bd docs: add project README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:52:33 +07:00
oskarvitaliiandClaude Opus 4.8 482a788f10 chore: nest project under Arduino/WeatherPredictor
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:41:01 +07:00
oskarvitaliiandClaude Opus 4.8 2e3af80188 feat(display): mini forecast icon + Wi-Fi IP, BGR colour fix
Rework the 160x80 status screen: large predicted-weather icon in the
right window (scaled to fill), Wi-Fi/IP line at the bottom (or 'offline'),
colour-coded forecast category, and the trend arrow shown only when
rising/falling. Colours are built R/B-swapped for this BGR ST7735S panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 23:05:13 +07:00
oskarvitaliiandClaude Opus 4.8 f1fe0b728f feat: forecast change log
Record each forecast change (Zambretti letter transition) with a
timestamp into a 24-entry ring buffer, persisted to LittleFS and
restored on boot. New /api/forecasts endpoint and a 'Forecast log'
panel in the web UI (newest first, category colour, timestamp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:40:28 +07:00
oskarvitaliiandClaude Opus 4.8 f4a9a04207 feat(web): city presets for station settings
Add a City preset dropdown (Astana, Karaganda, Almaty, Bishkek,
Tashkent, Yekaterinburg, Omsk, Novosibirsk, Krasnoyarsk, Moscow,
Saint Petersburg) that fills altitude, coordinates and time zone from
average values. Manual entry and Custom remain; the matching preset is
auto-selected on load when coordinates match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:21:32 +07:00
oskarvitaliiandClaude Opus 4.8 8dc469be3d feat(web): UTC-offset time zone picker and labelled barograph axes
Time zone is now a UTC+/-HH dropdown (e.g. UTC+7) instead of raw
minutes. Barograph gains dual value axes (pressure left in hPa,
temperature right in C), start/end time labels and gridlines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:10:02 +07:00
oskarvitaliiandClaude Opus 4.8 26a5071e86 feat(web): redesign UI as a barometer instrument
Dark dusk-indigo panel with brass/enamel accents, serif almanac
headings + monospace instrument readouts. Signature element: an
antique barometer dial with a live needle and a ghost 'set hand' at
the pressure 3h ago -- the trend made physical. Barograph, stat tiles
and settings kept quiet. Fully self-contained (no external fonts/CDNs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:42:34 +07:00
oskarvitaliiandClaude Opus 4.8 74121a2ef2 fix: robust 3h trend across non-monotonic epochs (NTP jumps)
Scan all samples for the newest one >=3h old instead of assuming
strictly increasing timestamps, so an NTP time correction no longer
breaks trend detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:27:10 +07:00
oskarvitaliiandClaude Opus 4.8 e29faf641a feat: self-contained web UI with live view, SVG chart, settings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:21:59 +07:00
oskarvitaliiandClaude Opus 4.8 396688cd98 feat: HTTP server with REST API for current/history/settings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:15:16 +07:00
oskarvitaliiandClaude Opus 4.8 ea0256a5b9 feat: NTP time sync writing to DS3231 (by IP, DNS-independent)
This LAN's router does not resolve external hostnames, so NTP uses
hard-coded Cloudflare/Google anycast IPs instead of names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:05:29 +07:00
18 changed files with 964 additions and 37 deletions
+9
View File
@@ -0,0 +1,9 @@
# Arduino
A collection of Arduino / ESP microcontroller projects.
## Projects
- **[WeatherPredictor](WeatherPredictor/)** — autonomous barometric weather
station on a Wemos D1 mini (ESP8266): local Zambretti forecast, TFT display
and a self-hosted web interface. See its [README](WeatherPredictor/README.md).
+116
View File
@@ -0,0 +1,116 @@
# 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.
![The Weather Predictor web interface, live from the station](WeatherPredictor.jpg)
## 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).
+15
View File
@@ -6,8 +6,10 @@
#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;
@@ -27,6 +29,7 @@ static void sampleNow() {
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;
}
@@ -51,10 +54,17 @@ void setup() {
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"));
}
@@ -63,10 +73,15 @@ void setup() {
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) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+4 -1
View File
@@ -23,6 +23,9 @@ static const unsigned long FLUSH_INTERVAL_MS = 15UL * 60UL * 1000UL; // flush
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
@@ -30,5 +33,5 @@ static const float DEFAULT_LAT = 54.9870f;
static const float DEFAULT_LON = 82.8730f;
// ---- Network ----
#define NTP_SERVER "pool.ntp.org"
// NTP is done by hard-coded IP in rtc_time.cpp (this LAN's DNS is unreliable).
#define AP_NAME "WeatherPredictor-Setup"
+90 -35
View File
@@ -1,18 +1,41 @@
#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);
}
@@ -27,33 +50,50 @@ void displaySplash(const char* line1, const char* line2) {
tft.print(line2);
}
// Simple 34x34 weather icon at (x, y).
static void drawIcon(WxCategory c, int x, int y) {
uint16_t sun = ST77XX_YELLOW;
uint16_t cloud = ST77XX_WHITE;
uint16_t rain = ST77XX_CYAN;
// 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:
tft.fillCircle(x + 17, y + 17, 11, sun);
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 + 12, y + 12, 8, sun);
tft.fillRoundRect(x + 8, y + 18, 24, 12, 6, cloud);
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 + 4, y + 12, 26, 14, 7, cloud);
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 + 4, y + 8, 26, 14, 7, cloud);
for (int i = 0; i < 3; i++)
tft.drawFastVLine(x + 9 + i * 8, y + 24, 8, rain);
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 + 4, y + 8, 26, 14, 7, cloud);
tft.fillTriangle(x + 16, y + 22, x + 12, y + 32, x + 20, y + 30, ST77XX_YELLOW);
}
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.drawRect(x + 4, y + 10, 26, 18, cloud); // unknown / collecting
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;
}
}
@@ -67,36 +107,51 @@ void displayRender(const AppState& s) {
// Time (large), top-left
snprintf(buf, sizeof(buf), "%02u:%02u", s.now.hour, s.now.minute);
tft.setTextSize(2);
tft.setCursor(4, 4);
tft.setCursor(2, 2);
tft.print(buf);
// Icon, top-right
drawIcon(s.haveTrend ? s.forecast.category : WX_UNKNOWN, 120, 4);
// Date
snprintf(buf, sizeof(buf), "%04u-%02u-%02u", s.now.year, s.now.month, s.now.day);
tft.setTextSize(1);
tft.setCursor(4, 26);
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
// Pressure + trend arrow (arrow only when rising/falling)
snprintf(buf, sizeof(buf), "%.0f hPa", s.mslHpa);
tft.setCursor(4, 40);
tft.setCursor(2, 34);
tft.print(buf);
const char* arrow = (s.trend == TREND_RISING) ? "^" : (s.trend == TREND_FALLING ? "v" : "=");
tft.setCursor(96, 40);
tft.setTextColor(ST77XX_CYAN);
tft.print(arrow);
tft.setTextColor(ST77XX_WHITE);
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(4, 52);
tft.setCursor(2, 46);
tft.print(buf);
// Forecast short label (full width, bottom row)
tft.setTextColor(ST77XX_YELLOW);
tft.setCursor(4, 66);
// 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);
}
+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();
+5
View File
@@ -88,6 +88,11 @@ Forecast computeForecast(float p, Trend trend, int month) {
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";
+1
View File
@@ -13,3 +13,4 @@ struct Forecast {
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'
+6 -1
View File
@@ -26,10 +26,15 @@ 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++) {
if (historyGet(i).epoch <= target) idx = i; else break;
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);
+22
View File
@@ -1,4 +1,7 @@
#include <RTClib.h>
#include <time.h>
#include <ESP8266WiFi.h>
#include "config.h"
#include "rtc_time.h"
static RTC_DS3231 rtc;
@@ -18,3 +21,22 @@ void rtcSet(const RtcTime& t) {
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;
}
+1
View File
@@ -11,3 +11,4 @@ bool rtcLostPower();
RtcTime rtcNow();
void rtcSet(const RtcTime& t);
uint32_t rtcEpoch();
bool ntpSync(int tzOffsetMin); // true if synced and RTC updated
+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();