C1 (critical): Add 15s watchdog for WEATHER_REQUESTING state — resets to IDLE if TCP connection hangs silently, preventing permanent weather death C2 (critical): Fix millis() rollover in all retry timers — replace unsafe `millis() >= nextRetryTime` with subtraction-safe `(millis() - nextRetryTime) < 0x80000000UL` in RetryConfig and WiFiRetryConfig; fix boot guard with static flag instead of raw millis() comparison H1 (high): Fix DST last-Sunday formula — was using incorrect year-only heuristic; now derives weekday of the 31st from current day's tm_wday: `weekdayOf31 = (tm_wday + (31 - day)) % 7`. Verified: March 2026 = 29th ✓ H2 (high): Replace String+= with snprintf+sendContent in handleAPITime, handleAPIStatus, handleAPIDebug, handleAPIWeather — eliminates permanent heap fragmentation from JS polling every second H3 (high): Add volatile to weatherState and ntpState — shared between ESPAsyncTCP callbacks and main loop; prevents stale register-cached reads RAM: 37,268 bytes (46%) — reduced from 37,560 due to String elimination Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
178 lines
6.4 KiB
C++
178 lines
6.4 KiB
C++
/*
|
|
* weather.cpp - Weather API functions
|
|
* TJ-56-654 Weather Clock v1.9.3
|
|
*/
|
|
|
|
// Include AsyncHTTPRequest BEFORE globals.h to provide full type definition
|
|
#include <ESPAsyncTCP.h>
|
|
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN_TARGET "AsyncHTTPRequest_Generic v1.13.0"
|
|
#define ASYNCHTTPREQUEST_GENERIC_VERSION_MIN 1013000
|
|
#include <AsyncHTTPRequest_Generic.h>
|
|
|
|
#include "globals.h"
|
|
#include <ArduinoJson.h>
|
|
|
|
// Async HTTP client for weather (local to this file)
|
|
static AsyncHTTPRequest weatherRequest;
|
|
|
|
// Weather response callback
|
|
void ICACHE_FLASH_ATTR onWeatherResponse(void* optParm, AsyncHTTPRequest* request, int readyState) {
|
|
(void)optParm; // Unused
|
|
|
|
if (readyState == 4) { // Request complete
|
|
weatherState = WEATHER_IDLE;
|
|
|
|
int httpCode = request->responseHTTPcode();
|
|
if (httpCode == 200) {
|
|
String payload = request->responseText();
|
|
Serial.printf("Weather response: %d bytes\n", payload.length());
|
|
|
|
// Parse JSON response
|
|
JsonDocument doc;
|
|
DeserializationError error = deserializeJson(doc, payload);
|
|
|
|
if (!error) {
|
|
// Extract current weather
|
|
JsonObject current = doc["current_weather"];
|
|
weather.temperature = current["temperature"] | 0.0f;
|
|
weather.weathercode = current["weathercode"] | -1;
|
|
weather.windspeed = current["windspeed"] | 0.0f;
|
|
weather.lastUpdate = millis();
|
|
weather.valid = true;
|
|
|
|
// Extract sunrise/sunset
|
|
JsonArray daily_sunrise = doc["daily"]["sunrise"];
|
|
JsonArray daily_sunset = doc["daily"]["sunset"];
|
|
|
|
if (daily_sunrise.size() > 0 && daily_sunset.size() > 0) {
|
|
const char* sunrise_str = daily_sunrise[0];
|
|
const char* sunset_str = daily_sunset[0];
|
|
|
|
// Parse ISO time (2026-01-02T07:52) -> HH:MM
|
|
if (sunrise_str && strlen(sunrise_str) >= 16) {
|
|
sunTimes.sunrise[0] = sunrise_str[11];
|
|
sunTimes.sunrise[1] = sunrise_str[12];
|
|
sunTimes.sunrise[2] = ':';
|
|
sunTimes.sunrise[3] = sunrise_str[14];
|
|
sunTimes.sunrise[4] = sunrise_str[15];
|
|
sunTimes.sunrise[5] = '\0';
|
|
|
|
sunTimes.sunriseMinutes = (sunrise_str[11] - '0') * 600 +
|
|
(sunrise_str[12] - '0') * 60 +
|
|
(sunrise_str[14] - '0') * 10 +
|
|
(sunrise_str[15] - '0');
|
|
}
|
|
|
|
if (sunset_str && strlen(sunset_str) >= 16) {
|
|
sunTimes.sunset[0] = sunset_str[11];
|
|
sunTimes.sunset[1] = sunset_str[12];
|
|
sunTimes.sunset[2] = ':';
|
|
sunTimes.sunset[3] = sunset_str[14];
|
|
sunTimes.sunset[4] = sunset_str[15];
|
|
sunTimes.sunset[5] = '\0';
|
|
|
|
sunTimes.sunsetMinutes = (sunset_str[11] - '0') * 600 +
|
|
(sunset_str[12] - '0') * 60 +
|
|
(sunset_str[14] - '0') * 10 +
|
|
(sunset_str[15] - '0');
|
|
}
|
|
|
|
// Update lastDay
|
|
time_t epochTime = timeClient.getEpochTime();
|
|
struct tm *ptm = gmtime(&epochTime);
|
|
sunTimes.lastDay = ptm->tm_yday;
|
|
}
|
|
|
|
// weatherState stays WEATHER_IDLE (set at readyState==4 entry) — allows periodic refresh
|
|
weatherRetry.reset();
|
|
Serial.printf("Weather: %.1f C, code %d, wind %.1f km/h\n",
|
|
weather.temperature, weather.weathercode, weather.windspeed);
|
|
} else {
|
|
weatherState = WEATHER_FAILED;
|
|
weather.valid = false;
|
|
lastError = String("JSON: ") + error.c_str();
|
|
Serial.printf("JSON parse error (attempt %d/%d): %s\n",
|
|
weatherRetry.currentRetry + 1, weatherRetry.maxRetries, error.c_str());
|
|
|
|
weatherRetry.scheduleRetry();
|
|
if (weatherRetry.maxRetriesReached()) {
|
|
// Reset so periodic refresh can retry after weatherInterval — prevents permanent lockup
|
|
Serial.println("Weather max retries reached, resetting for next interval");
|
|
weatherRetry.reset();
|
|
weatherState = WEATHER_IDLE;
|
|
} else {
|
|
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
|
|
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
|
}
|
|
}
|
|
|
|
doc.clear();
|
|
} else {
|
|
weatherState = WEATHER_FAILED;
|
|
weather.valid = false;
|
|
lastError = "Weather API: " + String(httpCode);
|
|
Serial.printf("HTTP error %d (attempt %d/%d)\n",
|
|
httpCode, weatherRetry.currentRetry + 1, weatherRetry.maxRetries);
|
|
|
|
weatherRetry.scheduleRetry();
|
|
if (weatherRetry.maxRetriesReached()) {
|
|
// Reset so periodic refresh can retry after weatherInterval — prevents permanent lockup
|
|
Serial.println("Weather max retries reached, resetting for next interval");
|
|
weatherRetry.reset();
|
|
weatherState = WEATHER_IDLE;
|
|
} else {
|
|
unsigned long backoff = weatherRetry.getBackoffDelay() / 1000;
|
|
Serial.printf(" Retry scheduled in %lu seconds\n", backoff);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Async weather fetch - non-blocking!
|
|
void ICACHE_FLASH_ATTR fetchWeatherAsync() {
|
|
if (!config.weather_enabled) {
|
|
Serial.println(F("Weather disabled"));
|
|
return;
|
|
}
|
|
|
|
if (weatherState != WEATHER_IDLE) {
|
|
Serial.println(F("Weather request already in progress"));
|
|
return;
|
|
}
|
|
|
|
// Build URL
|
|
String url = "http://api.open-meteo.com/v1/forecast?";
|
|
url += "latitude=" + String(config.latitude, 2);
|
|
url += "&longitude=" + String(config.longitude, 2);
|
|
url += "¤t_weather=true";
|
|
url += "&daily=sunrise,sunset";
|
|
url += "&timezone=auto";
|
|
url += "&forecast_days=1";
|
|
|
|
Serial.println(F("Fetching weather (async)..."));
|
|
|
|
if (weatherRequest.open("GET", url.c_str())) {
|
|
weatherRequest.onReadyStateChange(onWeatherResponse);
|
|
weatherRequest.setTimeout(10); // 10 seconds
|
|
weatherRequest.send();
|
|
weatherState = WEATHER_REQUESTING;
|
|
weatherRequestStart = millis();
|
|
Serial.println("Weather request sent (non-blocking)");
|
|
} else {
|
|
weatherState = WEATHER_FAILED;
|
|
Serial.println("Failed to open weather request");
|
|
}
|
|
}
|
|
|
|
// Calculate sun times (placeholder - data comes from API)
|
|
void ICACHE_FLASH_ATTR calculateSunTimes() {
|
|
if (!config.show_sunrise_sunset) return;
|
|
|
|
if (sunTimes.lastDay != -1) {
|
|
Serial.println(F("Sun times already available from API"));
|
|
return;
|
|
}
|
|
|
|
Serial.println(F("Sun times not available yet - will be fetched with weather"));
|
|
}
|