diff --git a/WeatherPredictor/WeatherPredictor.ino b/WeatherPredictor/WeatherPredictor.ino index b41dfa9..72715c6 100644 --- a/WeatherPredictor/WeatherPredictor.ino +++ b/WeatherPredictor/WeatherPredictor.ino @@ -6,6 +6,7 @@ #include "display_ui.h" #include "forecast.h" #include "history.h" +#include "flog.h" #include "app_settings.h" #include "net.h" #include "web_server.h" @@ -28,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; } @@ -52,6 +54,9 @@ 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()) { diff --git a/WeatherPredictor/config.h b/WeatherPredictor/config.h index 66f1b81..721eef0 100644 --- a/WeatherPredictor/config.h +++ b/WeatherPredictor/config.h @@ -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 diff --git a/WeatherPredictor/flog.cpp b/WeatherPredictor/flog.cpp new file mode 100644 index 0000000..dc9a92b --- /dev/null +++ b/WeatherPredictor/flog.cpp @@ -0,0 +1,62 @@ +#include +#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; +} diff --git a/WeatherPredictor/flog.h b/WeatherPredictor/flog.h new file mode 100644 index 0000000..a02dbe4 --- /dev/null +++ b/WeatherPredictor/flog.h @@ -0,0 +1,16 @@ +#pragma once +#include +#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(); diff --git a/WeatherPredictor/forecast.cpp b/WeatherPredictor/forecast.cpp index caccdd2..aba400f 100644 --- a/WeatherPredictor/forecast.cpp +++ b/WeatherPredictor/forecast.cpp @@ -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"; diff --git a/WeatherPredictor/forecast.h b/WeatherPredictor/forecast.h index 40db7c7..63e06dc 100644 --- a/WeatherPredictor/forecast.h +++ b/WeatherPredictor/forecast.h @@ -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' diff --git a/WeatherPredictor/web_page.h b/WeatherPredictor/web_page.h index 363459e..ee53b90 100644 --- a/WeatherPredictor/web_page.h +++ b/WeatherPredictor/web_page.h @@ -100,6 +100,18 @@ static const char INDEX_HTML[] PROGMEM = R"HTML( .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{ @@ -180,6 +192,15 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(

Collecting data…

+ +
+
+

Forecast log

+ +
+
    +
    +
    Station settings Adjust @@ -318,9 +339,35 @@ async function refresh(){ // 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){ diff --git a/WeatherPredictor/web_server.cpp b/WeatherPredictor/web_server.cpp index cfcfe16..f5447d8 100644 --- a/WeatherPredictor/web_server.cpp +++ b/WeatherPredictor/web_server.cpp @@ -3,6 +3,7 @@ #include "state.h" #include "app_settings.h" #include "history.h" +#include "flog.h" #include "forecast.h" #include "web_page.h" @@ -47,6 +48,22 @@ static void handleHistory() { server.send(200, "application/json", out); } +static void handleForecasts() { + JsonDocument doc; + JsonArray arr = doc.to(); + int n = flogCount(); + for (int i = n - 1; i >= 0; i--) { // newest first + FcastEntry e = flogGet(i); + JsonObject o = arr.add(); + 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; @@ -76,6 +93,7 @@ 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();