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>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
#include "display_ui.h"
|
#include "display_ui.h"
|
||||||
#include "forecast.h"
|
#include "forecast.h"
|
||||||
#include "history.h"
|
#include "history.h"
|
||||||
|
#include "flog.h"
|
||||||
#include "app_settings.h"
|
#include "app_settings.h"
|
||||||
#include "net.h"
|
#include "net.h"
|
||||||
#include "web_server.h"
|
#include "web_server.h"
|
||||||
@@ -28,6 +29,7 @@ static void sampleNow() {
|
|||||||
if (g_state.haveTrend) {
|
if (g_state.haveTrend) {
|
||||||
g_state.trend = classifyTrend(d);
|
g_state.trend = classifyTrend(d);
|
||||||
g_state.forecast = computeForecast(g_state.mslHpa, g_state.trend, g_state.now.month);
|
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 {
|
} else {
|
||||||
g_state.trend = TREND_STEADY;
|
g_state.trend = TREND_STEADY;
|
||||||
}
|
}
|
||||||
@@ -52,6 +54,9 @@ void setup() {
|
|||||||
historyBegin();
|
historyBegin();
|
||||||
historyLoad(); // restore prior samples (empty on first ever boot)
|
historyLoad(); // restore prior samples (empty on first ever boot)
|
||||||
Serial.printf("history restored: %d samples\n", historyCount());
|
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");
|
displaySplash("WiFi", "Connect / portal");
|
||||||
if (netBegin()) {
|
if (netBegin()) {
|
||||||
|
|||||||
@@ -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 int HISTORY_SIZE = 288; // 24 h @ 5 min
|
||||||
static const float TREND_THRESHOLD_HPA = 1.6f; // >|1.6| hPa / 3 h = rising/falling
|
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) ----
|
// ---- Defaults (editable via web) ----
|
||||||
static const float DEFAULT_ALTITUDE_M = 150.0f;
|
static const float DEFAULT_ALTITUDE_M = 150.0f;
|
||||||
static const int DEFAULT_TZ_OFFSET_MIN = 7 * 60; // UTC+7
|
static const int DEFAULT_TZ_OFFSET_MIN = 7 * 60; // UTC+7
|
||||||
|
|||||||
@@ -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) };
|
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) {
|
const char* categoryShort(WxCategory c) {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
case WX_FINE: return "Fine";
|
case WX_FINE: return "Fine";
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ struct Forecast {
|
|||||||
Trend classifyTrend(float deltaHpa3h);
|
Trend classifyTrend(float deltaHpa3h);
|
||||||
Forecast computeForecast(float mslHpa, Trend trend, int month);
|
Forecast computeForecast(float mslHpa, Trend trend, int month);
|
||||||
const char* categoryShort(WxCategory c);
|
const char* categoryShort(WxCategory c);
|
||||||
|
const char* forecastTextForLetter(char letter); // full Zambretti phrase for 'A'..'Z'
|
||||||
|
|||||||
@@ -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;
|
.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)}
|
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}
|
.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 */
|
/* Settings */
|
||||||
details.set summary{
|
details.set summary{
|
||||||
@@ -180,6 +192,15 @@ static const char INDEX_HTML[] PROGMEM = R"HTML(
|
|||||||
<p class="hint" id="hint">Collecting data…</p>
|
<p class="hint" id="hint">Collecting data…</p>
|
||||||
</section>
|
</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 -->
|
<!-- Settings -->
|
||||||
<details class="panel set">
|
<details class="panel set">
|
||||||
<summary>Station settings <span class="caret">Adjust</span></summary>
|
<summary>Station settings <span class="caret">Adjust</span></summary>
|
||||||
@@ -318,9 +339,35 @@ async function refresh(){
|
|||||||
// absolute already implied; keep footer clean
|
// absolute already implied; keep footer clean
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
await drawChart();
|
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 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){
|
function fillAxis(id,mx,mn,deg){
|
||||||
var box=document.getElementById(id); box.innerHTML="";
|
var box=document.getElementById(id); box.innerHTML="";
|
||||||
[mx,(mx+mn)/2,mn].forEach(function(v){
|
[mx,(mx+mn)/2,mn].forEach(function(v){
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "state.h"
|
#include "state.h"
|
||||||
#include "app_settings.h"
|
#include "app_settings.h"
|
||||||
#include "history.h"
|
#include "history.h"
|
||||||
|
#include "flog.h"
|
||||||
#include "forecast.h"
|
#include "forecast.h"
|
||||||
#include "web_page.h"
|
#include "web_page.h"
|
||||||
|
|
||||||
@@ -47,6 +48,22 @@ static void handleHistory() {
|
|||||||
server.send(200, "application/json", 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() {
|
static void handleGetSettings() {
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
doc["altitude"] = settings.altitudeM;
|
doc["altitude"] = settings.altitudeM;
|
||||||
@@ -76,6 +93,7 @@ void webBegin() {
|
|||||||
server.on("/", handleRoot);
|
server.on("/", handleRoot);
|
||||||
server.on("/api/current", handleCurrent);
|
server.on("/api/current", handleCurrent);
|
||||||
server.on("/api/history", handleHistory);
|
server.on("/api/history", handleHistory);
|
||||||
|
server.on("/api/forecasts", handleForecasts);
|
||||||
server.on("/api/settings", HTTP_GET, handleGetSettings);
|
server.on("/api/settings", HTTP_GET, handleGetSettings);
|
||||||
server.on("/api/settings", HTTP_POST, handlePostSettings);
|
server.on("/api/settings", HTTP_POST, handlePostSettings);
|
||||||
server.begin();
|
server.begin();
|
||||||
|
|||||||
Reference in New Issue
Block a user