Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
482a788f10 | ||
|
|
2e3af80188 | ||
|
|
f1fe0b728f | ||
|
|
f4a9a04207 | ||
|
|
8dc469be3d | ||
|
|
26a5071e86 | ||
|
|
74121a2ef2 | ||
|
|
e29faf641a | ||
|
|
396688cd98 | ||
|
|
ea0256a5b9 |
@@ -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) {
|
||||
@@ -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"
|
||||
@@ -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,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();
|
||||
@@ -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";
|
||||
@@ -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'
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -1,102 +0,0 @@
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <Adafruit_ST7735.h>
|
||||
#include <SPI.h>
|
||||
#include "config.h"
|
||||
#include "display_ui.h"
|
||||
|
||||
// Non-static so displayRender() can reference it via extern.
|
||||
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
|
||||
|
||||
void displayBegin() {
|
||||
pinMode(TFT_BLK, OUTPUT);
|
||||
digitalWrite(TFT_BLK, HIGH); // backlight on
|
||||
tft.initR(INITR_MINI160x80); // 0.96" ST7735S
|
||||
tft.invertDisplay(false); // this panel: no inversion
|
||||
tft.setRotation(3); // landscape, 160 wide x 80 tall
|
||||
tft.fillScreen(ST77XX_BLACK);
|
||||
}
|
||||
|
||||
void displaySplash(const char* line1, const char* line2) {
|
||||
tft.fillScreen(ST77XX_BLACK);
|
||||
tft.setTextColor(ST77XX_WHITE);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(4, 20);
|
||||
tft.print(line1);
|
||||
tft.setTextSize(1);
|
||||
tft.setCursor(4, 50);
|
||||
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;
|
||||
switch (c) {
|
||||
case WX_FINE:
|
||||
tft.fillCircle(x + 17, y + 17, 11, sun);
|
||||
break;
|
||||
case WX_FAIR:
|
||||
tft.fillCircle(x + 12, y + 12, 8, sun);
|
||||
tft.fillRoundRect(x + 8, y + 18, 24, 12, 6, cloud);
|
||||
break;
|
||||
case WX_CHANGEABLE:
|
||||
tft.fillRoundRect(x + 4, y + 12, 26, 14, 7, 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);
|
||||
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);
|
||||
break;
|
||||
default:
|
||||
tft.drawRect(x + 4, y + 10, 26, 18, cloud); // unknown / collecting
|
||||
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(4, 4);
|
||||
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);
|
||||
tft.print(buf);
|
||||
|
||||
// Pressure + trend arrow
|
||||
snprintf(buf, sizeof(buf), "%.0f hPa", s.mslHpa);
|
||||
tft.setCursor(4, 40);
|
||||
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);
|
||||
|
||||
// Temperature
|
||||
snprintf(buf, sizeof(buf), "%.1f C", s.tempC);
|
||||
tft.setCursor(4, 52);
|
||||
tft.print(buf);
|
||||
|
||||
// Forecast short label (full width, bottom row)
|
||||
tft.setTextColor(ST77XX_YELLOW);
|
||||
tft.setCursor(4, 66);
|
||||
tft.print(s.haveTrend ? categoryShort(s.forecast.category) : "Collecting...");
|
||||
tft.setTextColor(ST77XX_WHITE);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
#include <RTClib.h>
|
||||
#include "rtc_time.h"
|
||||
|
||||
static RTC_DS3231 rtc;
|
||||
|
||||
bool rtcBegin() { return rtc.begin(); }
|
||||
bool rtcLostPower() { return rtc.lostPower(); }
|
||||
|
||||
RtcTime rtcNow() {
|
||||
DateTime n = rtc.now();
|
||||
return RtcTime{ n.year(), n.month(), n.day(), n.hour(), n.minute(), n.second() };
|
||||
}
|
||||
|
||||
void rtcSet(const RtcTime& t) {
|
||||
rtc.adjust(DateTime(t.year, t.month, t.day, t.hour, t.minute, t.second));
|
||||
}
|
||||
|
||||
uint32_t rtcEpoch() {
|
||||
return rtc.now().unixtime();
|
||||
}
|
||||
Reference in New Issue
Block a user