Merge branch 'feat-weather' of https://github.com/jpirnay/crosspoint-reader into mybuild

This commit is contained in:
jpirnay
2026-04-02 20:19:43 +02:00
18 changed files with 200 additions and 447 deletions
+12
View File
@@ -366,6 +366,18 @@ STR_WEATHER_DAY_THU: "Thu"
STR_WEATHER_DAY_FRI: "Fri" STR_WEATHER_DAY_FRI: "Fri"
STR_WEATHER_DAY_SAT: "Sat" STR_WEATHER_DAY_SAT: "Sat"
STR_WEATHER_DAY_SUN: "Sun" STR_WEATHER_DAY_SUN: "Sun"
STR_WEATHER_MONTH_JAN: "Jan"
STR_WEATHER_MONTH_FEB: "Feb"
STR_WEATHER_MONTH_MAR: "Mar"
STR_WEATHER_MONTH_APR: "Apr"
STR_WEATHER_MONTH_MAY: "May"
STR_WEATHER_MONTH_JUN: "Jun"
STR_WEATHER_MONTH_JUL: "Jul"
STR_WEATHER_MONTH_AUG: "Aug"
STR_WEATHER_MONTH_SEP: "Sep"
STR_WEATHER_MONTH_OCT: "Oct"
STR_WEATHER_MONTH_NOV: "Nov"
STR_WEATHER_MONTH_DEC: "Dec"
STR_WEATHER_DESC_CLEAR_SKY: "Clear sky" STR_WEATHER_DESC_CLEAR_SKY: "Clear sky"
STR_WEATHER_DESC_MAINLY_CLEAR: "Mainly clear" STR_WEATHER_DESC_MAINLY_CLEAR: "Mainly clear"
STR_WEATHER_DESC_PARTLY_CLOUDY: "Partly cloudy" STR_WEATHER_DESC_PARTLY_CLOUDY: "Partly cloudy"
+12
View File
@@ -350,6 +350,18 @@ STR_WEATHER_DAY_THU: "Do"
STR_WEATHER_DAY_FRI: "Fr" STR_WEATHER_DAY_FRI: "Fr"
STR_WEATHER_DAY_SAT: "Sa" STR_WEATHER_DAY_SAT: "Sa"
STR_WEATHER_DAY_SUN: "So" STR_WEATHER_DAY_SUN: "So"
STR_WEATHER_MONTH_JAN: "Jan"
STR_WEATHER_MONTH_FEB: "Feb"
STR_WEATHER_MONTH_MAR: "Mär"
STR_WEATHER_MONTH_APR: "Apr"
STR_WEATHER_MONTH_MAY: "Mai"
STR_WEATHER_MONTH_JUN: "Jun"
STR_WEATHER_MONTH_JUL: "Jul"
STR_WEATHER_MONTH_AUG: "Aug"
STR_WEATHER_MONTH_SEP: "Sep"
STR_WEATHER_MONTH_OCT: "Okt"
STR_WEATHER_MONTH_NOV: "Nov"
STR_WEATHER_MONTH_DEC: "Dez"
STR_WEATHER_DESC_CLEAR_SKY: "Klarer Himmel" STR_WEATHER_DESC_CLEAR_SKY: "Klarer Himmel"
STR_WEATHER_DESC_MAINLY_CLEAR: "Überwiegend klar" STR_WEATHER_DESC_MAINLY_CLEAR: "Überwiegend klar"
+45 -11
View File
@@ -4,6 +4,7 @@
#include <HalStorage.h> #include <HalStorage.h>
#include <Logging.h> #include <Logging.h>
#include <cstdio>
#include <ctime> #include <ctime>
#include "../../src/network/HttpDownloader.h" #include "../../src/network/HttpDownloader.h"
@@ -12,9 +13,7 @@ namespace {
constexpr char WEATHER_CACHE_FILE[] = "/.crosspoint/weather_cache.json"; constexpr char WEATHER_CACHE_FILE[] = "/.crosspoint/weather_cache.json";
std::string buildForecastUrl(const WeatherSettingsStore& settings) { std::string buildForecastUrl(const WeatherSettingsStore& settings) {
// Open-Meteo supports plain HTTP. Using HTTP here avoids TLS handshake std::string url = "https://api.open-meteo.com/v1/forecast?";
// instability observed on some ESP32-C3 builds.
std::string url = "http://api.open-meteo.com/v1/forecast?";
url += "latitude=" + std::to_string(settings.getLatitude()); url += "latitude=" + std::to_string(settings.getLatitude());
url += "&longitude=" + std::to_string(settings.getLongitude()); url += "&longitude=" + std::to_string(settings.getLongitude());
url += url +=
@@ -33,8 +32,33 @@ std::string buildForecastUrl(const WeatherSettingsStore& settings) {
url += "&forecast_hours=48"; url += "&forecast_hours=48";
return url; return url;
} }
std::string buildRequestSignature(const WeatherSettingsStore& settings) {
char latitude[24];
char longitude[24];
snprintf(latitude, sizeof(latitude), "%.6f", settings.getLatitude());
snprintf(longitude, sizeof(longitude), "%.6f", settings.getLongitude());
std::string signature = "lat=";
signature += latitude;
signature += "|lon=";
signature += longitude;
signature += "|temp=";
signature += settings.getTempUnitParam();
signature += "|wind=";
signature += settings.getWindUnitParam();
signature += "|precip=";
signature += settings.getPrecipUnitParam();
signature += "|days=";
signature += std::to_string(settings.getForecastDays());
return signature;
}
} // namespace } // namespace
std::string WeatherClient::buildRequestSignature(const WeatherSettingsStore& settings) {
return ::buildRequestSignature(settings);
}
WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool forceRefresh) { WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool forceRefresh) {
if (!settings.hasLocation()) { if (!settings.hasLocation()) {
WeatherData data; WeatherData data;
@@ -42,19 +66,26 @@ WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool
return data; return data;
} }
const std::string requestSignature = buildRequestSignature(settings);
if (!forceRefresh) { if (!forceRefresh) {
WeatherData cached; WeatherData cached;
if (loadCache(cached) && cached.valid) { if (loadCache(cached) && cached.valid) {
time_t now; if (cached.requestSignature != requestSignature) {
time(&now); LOG_DBG("WEA", "Ignoring cache with mismatched request signature");
if (now - cached.fetchedAt < CACHE_TTL_SECONDS) { Storage.remove(WEATHER_CACHE_FILE);
LOG_DBG("WEA", "Using cached weather data (age: %ld s)", (long)(now - cached.fetchedAt)); } else {
time_t now;
time(&now);
if (now - cached.fetchedAt < CACHE_TTL_SECONDS) {
LOG_DBG("WEA", "Using cached weather data (age: %ld s)", (long)(now - cached.fetchedAt));
return cached;
}
LOG_DBG("WEA", "Cache expired (age: %ld s)", (long)(now - cached.fetchedAt));
// Cache-first behavior: return stale cache and let the caller decide
// whether/when to perform a network refresh.
return cached; return cached;
} }
LOG_DBG("WEA", "Cache expired (age: %ld s)", (long)(now - cached.fetchedAt));
// Cache-first behavior: return stale cache and let the caller decide
// whether/when to perform a network refresh.
return cached;
} }
LOG_DBG("WEA", "No cache available; caller should establish network and force refresh"); LOG_DBG("WEA", "No cache available; caller should establish network and force refresh");
@@ -68,6 +99,7 @@ WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool
WeatherData WeatherClient::fetchFromApi(const WeatherSettingsStore& settings) { WeatherData WeatherClient::fetchFromApi(const WeatherSettingsStore& settings) {
WeatherData data; WeatherData data;
data.requestSignature = buildRequestSignature(settings);
std::string url = buildForecastUrl(settings); std::string url = buildForecastUrl(settings);
LOG_DBG("WEA", "fetchFromApi[1] start"); LOG_DBG("WEA", "fetchFromApi[1] start");
LOG_DBG("WEA", "fetchFromApi[2] url length=%zu", url.size()); LOG_DBG("WEA", "fetchFromApi[2] url length=%zu", url.size());
@@ -188,6 +220,7 @@ bool WeatherClient::saveCache(const WeatherData& data) {
Storage.mkdir("/.crosspoint"); Storage.mkdir("/.crosspoint");
JsonDocument doc; JsonDocument doc;
doc["requestSignature"] = data.requestSignature;
doc["fetchedAt"] = data.fetchedAt; doc["fetchedAt"] = data.fetchedAt;
doc["timezone"] = data.timezone; doc["timezone"] = data.timezone;
doc["utcOffsetSeconds"] = data.utcOffsetSeconds; doc["utcOffsetSeconds"] = data.utcOffsetSeconds;
@@ -253,6 +286,7 @@ bool WeatherClient::loadCache(WeatherData& data) {
} }
data.fetchedAt = doc["fetchedAt"] | (time_t)0; data.fetchedAt = doc["fetchedAt"] | (time_t)0;
data.requestSignature = doc["requestSignature"] | std::string("");
data.timezone = doc["timezone"] | std::string(""); data.timezone = doc["timezone"] | std::string("");
data.utcOffsetSeconds = doc["utcOffsetSeconds"] | 0; data.utcOffsetSeconds = doc["utcOffsetSeconds"] | 0;
+1
View File
@@ -17,6 +17,7 @@ class WeatherClient {
private: private:
static WeatherData fetchFromApi(const WeatherSettingsStore& settings); static WeatherData fetchFromApi(const WeatherSettingsStore& settings);
static bool parseWeatherJson(const std::string& json, WeatherData& data); static bool parseWeatherJson(const std::string& json, WeatherData& data);
static std::string buildRequestSignature(const WeatherSettingsStore& settings);
static bool saveCache(const WeatherData& data); static bool saveCache(const WeatherData& data);
static bool loadCache(WeatherData& data); static bool loadCache(WeatherData& data);
+1
View File
@@ -40,6 +40,7 @@ struct WeatherData {
CurrentWeather current; CurrentWeather current;
std::vector<DailyForecast> daily; std::vector<DailyForecast> daily;
std::vector<HourlyForecast> hourly; std::vector<HourlyForecast> hourly;
std::string requestSignature;
std::string timezone; std::string timezone;
int utcOffsetSeconds = 0; int utcOffsetSeconds = 0;
time_t fetchedAt = 0; time_t fetchedAt = 0;
+1 -237
View File
@@ -4,180 +4,7 @@
// Weather icon glyph source attribution: // Weather icon glyph source attribution:
// https://github.com/erikflowers/weather-icons // https://github.com/erikflowers/weather-icons
// 64x64 monochrome icon bitmaps in this file are adapted from that icon set. // Large icons are provided by generated WI48_* arrays in WeatherIcons48.h.
// ============================================================================
// 64x64 Large Weather Icons (1-bit, MSB-first, row-major)
// Each row = 64 pixels = 8 bytes. Total = 64 * 8 = 512 bytes per icon.
// Pixel ON (1) = white/clear, Pixel OFF (0) = black/drawn.
// These are "inverted" bitmaps - 0 bits are drawn as black pixels.
// ============================================================================
// Clear Day - Sun with rays
// clang-format off
static const uint8_t ICON_CLEAR_DAY_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF,
0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF,
0xFF,0x8F,0xFE,0x7F,0xF1,0xFF, 0xFF,0x1F,0xFE,0x7F,0xF8,0xFF, 0xFF,0x3F,0x80,0x01,0xFC,0xFF,
0xFF,0x7F,0x00,0x00,0xFE,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF,
0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xE0,0xC0,0x00,0x00,0x03,0x07,
0xE0,0x80,0x00,0x00,0x01,0x07, 0xF0,0x80,0x00,0x00,0x01,0x0F, 0xF8,0x80,0x00,0x00,0x01,0x1F,
0xFC,0x80,0x00,0x00,0x01,0x3F, 0xFC,0x80,0x00,0x00,0x01,0x3F, 0xF8,0x80,0x00,0x00,0x01,0x1F,
0xF0,0x80,0x00,0x00,0x01,0x0F, 0xE0,0x80,0x00,0x00,0x01,0x07, 0xE0,0xC0,0x00,0x00,0x03,0x07,
0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF,
0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF,
0xFF,0xFC,0x00,0x00,0x3F,0xFF, 0xFF,0x7F,0x00,0x00,0xFE,0xFF, 0xFF,0x3F,0x80,0x01,0xFC,0xFF,
0xFF,0x1F,0xFE,0x7F,0xF8,0xFF, 0xFF,0x8F,0xFE,0x7F,0xF1,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF,
0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF,
0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Clear Night - Moon crescent
static const uint8_t ICON_CLEAR_NIGHT_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFC,0x0F,0xFF,0xFF, 0xFF,0xFF,0xF0,0x03,0xFF,0xFF,
0xFF,0xFF,0xE0,0x01,0xFF,0xFF, 0xFF,0xFF,0xC0,0x00,0xFF,0xFF, 0xFF,0xFF,0x80,0x00,0x7F,0xFF,
0xFF,0xFF,0x00,0x00,0x3F,0xFF, 0xFF,0xFF,0x00,0x00,0x3F,0xFF, 0xFF,0xFE,0x00,0x00,0x1F,0xFF,
0xFF,0xFE,0x00,0x00,0x3F,0xFF, 0xFF,0xFC,0x00,0x00,0x7F,0xFF, 0xFF,0xFC,0x00,0x00,0xFF,0xFF,
0xFF,0xFC,0x00,0x01,0xFF,0xFF, 0xFF,0xF8,0x00,0x03,0xFF,0xFF, 0xFF,0xF8,0x00,0x07,0xFF,0xFF,
0xFF,0xF8,0x00,0x07,0xFF,0xFF, 0xFF,0xF8,0x00,0x0F,0xFF,0xFF, 0xFF,0xF8,0x00,0x0F,0xFF,0xFF,
0xFF,0xF8,0x00,0x0F,0xFF,0xFF, 0xFF,0xF8,0x00,0x0F,0xFF,0xFF, 0xFF,0xF8,0x00,0x0F,0xFF,0xFF,
0xFF,0xF8,0x00,0x0F,0xFF,0xFF, 0xFF,0xF8,0x00,0x07,0xFF,0xFF, 0xFF,0xF8,0x00,0x07,0xFF,0xFF,
0xFF,0xF8,0x00,0x03,0xFF,0xFF, 0xFF,0xFC,0x00,0x01,0xFF,0xFF, 0xFF,0xFC,0x00,0x00,0xFF,0xFF,
0xFF,0xFC,0x00,0x00,0x7F,0xFF, 0xFF,0xFE,0x00,0x00,0x3F,0xFF, 0xFF,0xFE,0x00,0x00,0x1F,0xFF,
0xFF,0xFF,0x00,0x00,0x1F,0xFF, 0xFF,0xFF,0x00,0x00,0x3F,0xFF, 0xFF,0xFF,0x80,0x00,0x7F,0xFF,
0xFF,0xFF,0xC0,0x00,0xFF,0xFF, 0xFF,0xFF,0xE0,0x01,0xFF,0xFF, 0xFF,0xFF,0xF0,0x03,0xFF,0xFF,
0xFF,0xFF,0xFC,0x0F,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Partly Cloudy Day - Sun behind cloud
static const uint8_t ICON_PARTLY_CLOUDY_DAY_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF,
0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0xFF,0xFE,0x7F,0xFF,0xFF, 0xFF,0x9F,0xFE,0x7F,0xF9,0xFF,
0xFF,0x3F,0xFE,0x7F,0xFC,0xFF, 0xFF,0x7F,0x80,0x01,0xFE,0xFF, 0xFF,0xFF,0x00,0x00,0xFF,0xFF,
0xFF,0xFC,0x00,0x00,0x3F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF, 0xFF,0xF0,0x00,0x00,0x0F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xE0,0xE0,0x00,0x00,0x07,0x07, 0xF0,0xE0,0x00,0x00,0x07,0x0F,
0xF8,0xE0,0x00,0x00,0x07,0x1F, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xF0,0x00,0x00,0x0F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF,
0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF, 0xFF,0xF0,0x00,0x00,0x0F,0xFF,
0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFE,0x00,0x00,0x00,0x00,0x7F,
0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFF,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0xF0,0x00,0x00,0x0F,0xFF,
0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Partly Cloudy Night - Moon behind cloud (reuse partly cloudy day for simplicity)
static const uint8_t* ICON_PARTLY_CLOUDY_NIGHT_48 = ICON_PARTLY_CLOUDY_DAY_48;
// Overcast - Full cloud
static const uint8_t ICON_OVERCAST_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFC,0x3F,0xFF,0xFF,
0xFF,0xFF,0xF0,0x0F,0xFF,0xFF, 0xFF,0xFF,0xE0,0x07,0xFF,0xFF, 0xFF,0xFF,0xC0,0x03,0xFF,0xFF,
0xFF,0xFF,0x80,0x01,0xFF,0xFF, 0xFF,0xFF,0x00,0x00,0xFF,0xFF, 0xFF,0xFF,0x00,0x00,0xFF,0xFF,
0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFE,0x00,0x00,0x00,0x00,0x7F,
0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFF,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0xF0,0x00,0x00,0x0F,0xFF,
0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Rain - Cloud with rain drops
static const uint8_t ICON_RAIN_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFC,0x3F,0xFF,0xFF, 0xFF,0xFF,0xF0,0x0F,0xFF,0xFF,
0xFF,0xFF,0xE0,0x07,0xFF,0xFF, 0xFF,0xFF,0xC0,0x03,0xFF,0xFF, 0xFF,0xFF,0x80,0x01,0xFF,0xFF,
0xFF,0xFF,0x00,0x00,0xFF,0xFF, 0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFE,0x00,0x00,0x00,0x00,0x7F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFF,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFB,0xDF,0xBF,0x7F,0xFF,
0xFF,0xF3,0xCF,0x9F,0x3F,0xFF, 0xFF,0xF3,0xCF,0x9F,0x3F,0xFF, 0xFF,0xE7,0xE7,0xCF,0x9F,0xFF,
0xFF,0xE7,0xE7,0xCF,0x9F,0xFF, 0xFF,0xCF,0xF3,0xE7,0xCF,0xFF, 0xFF,0xCF,0xF3,0xE7,0xCF,0xFF,
0xFF,0xCF,0xF3,0xE7,0xCF,0xFF, 0xFF,0xDF,0xFB,0xF7,0xEF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Snow - Cloud with snowflakes
static const uint8_t ICON_SNOW_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFC,0x3F,0xFF,0xFF, 0xFF,0xFF,0xF0,0x0F,0xFF,0xFF,
0xFF,0xFF,0xE0,0x07,0xFF,0xFF, 0xFF,0xFF,0xC0,0x03,0xFF,0xFF, 0xFF,0xFF,0x80,0x01,0xFF,0xFF,
0xFF,0xFF,0x00,0x00,0xFF,0xFF, 0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFE,0x00,0x00,0x00,0x00,0x7F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFF,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xF9,0xE7,0xCF,0x3F,0xFF,
0xFF,0xFC,0xF3,0xE7,0x9F,0xFF, 0xFF,0xFE,0x79,0xF3,0xCF,0xFF, 0xFF,0xFF,0x3C,0xF9,0xE7,0xFF,
0xFF,0xFE,0x79,0xF3,0xCF,0xFF, 0xFF,0xFC,0xF3,0xE7,0x9F,0xFF, 0xFF,0xF9,0xE7,0xCF,0x3F,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Thunderstorm - Cloud with lightning bolt
static const uint8_t ICON_THUNDERSTORM_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFC,0x3F,0xFF,0xFF, 0xFF,0xFF,0xF0,0x0F,0xFF,0xFF,
0xFF,0xFF,0xE0,0x07,0xFF,0xFF, 0xFF,0xFF,0xC0,0x03,0xFF,0xFF, 0xFF,0xFF,0x80,0x01,0xFF,0xFF,
0xFF,0xFF,0x00,0x00,0xFF,0xFF, 0xFF,0xFE,0x00,0x00,0x7F,0xFF, 0xFF,0xF8,0x00,0x00,0x1F,0xFF,
0xFF,0xF0,0x00,0x00,0x0F,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFE,0x00,0x00,0x00,0x00,0x7F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFC,0x00,0x00,0x00,0x00,0x3F,
0xFC,0x00,0x00,0x00,0x00,0x3F, 0xFE,0x00,0x00,0x00,0x00,0x7F, 0xFF,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xE0,0x00,0x00,0x07,0xFF, 0xFF,0xFC,0x00,0x00,0x3F,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xF8,0xFF,0xFF,0xFF, 0xFF,0xFF,0xF1,0xFF,0xFF,0xFF,
0xFF,0xFF,0xE3,0xFF,0xFF,0xFF, 0xFF,0xFF,0xC7,0xFF,0xFF,0xFF, 0xFF,0xFF,0x80,0x3F,0xFF,0xFF,
0xFF,0xFF,0x00,0x7F,0xFF,0xFF, 0xFF,0xFF,0xF0,0xFF,0xFF,0xFF, 0xFF,0xFF,0xE1,0xFF,0xFF,0xFF,
0xFF,0xFF,0xC3,0xFF,0xFF,0xFF, 0xFF,0xFF,0x87,0xFF,0xFF,0xFF, 0xFF,0xFF,0x0F,0xFF,0xFF,0xFF,
0xFF,0xFF,0x1F,0xFF,0xFF,0xFF, 0xFF,0xFF,0x3F,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// Fog - Horizontal lines
static const uint8_t ICON_FOG_48[] = {
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF,
0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF,
0xFF,0xC0,0x00,0x00,0x03,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0x80,0x00,0x00,0x01,0xFF, 0xFF,0x00,0x00,0x00,0x00,0xFF, 0xFF,0x80,0x00,0x00,0x01,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
};
// clang-format on
// ============================================================================ // ============================================================================
// 24x24 Small Weather Icons (1-bit, MSB-first) // 24x24 Small Weather Icons (1-bit, MSB-first)
@@ -332,69 +159,6 @@ const uint8_t* getWeatherIconSmall(WeatherIconType type) {
} }
#endif // WEATHER_ENABLE_SMALL_ICONS #endif // WEATHER_ENABLE_SMALL_ICONS
const char* getWeatherDescription(int wmoCode) {
switch (wmoCode) {
case 0:
return "Clear sky";
case 1:
return "Mainly clear";
case 2:
return "Partly cloudy";
case 3:
return "Overcast";
case 45:
return "Fog";
case 48:
return "Rime fog";
case 51:
return "Light drizzle";
case 53:
return "Drizzle";
case 55:
return "Dense drizzle";
case 56:
return "Freezing drizzle";
case 57:
return "Dense freezing drizzle";
case 61:
return "Slight rain";
case 63:
return "Moderate rain";
case 65:
return "Heavy rain";
case 66:
return "Freezing rain";
case 67:
return "Heavy freezing rain";
case 71:
return "Slight snow";
case 73:
return "Moderate snow";
case 75:
return "Heavy snow";
case 77:
return "Snow grains";
case 80:
return "Slight showers";
case 81:
return "Moderate showers";
case 82:
return "Violent showers";
case 85:
return "Snow showers";
case 86:
return "Heavy snow showers";
case 95:
return "Thunderstorm";
case 96:
return "Thunderstorm, hail";
case 99:
return "Thunderstorm, heavy hail";
default:
return "Unknown";
}
}
const char* getWindDirectionText(int degrees) { const char* getWindDirectionText(int degrees) {
// Normalize to 0-360 // Normalize to 0-360
degrees = ((degrees % 360) + 360) % 360; degrees = ((degrees % 360) + 360) % 360;
+2 -4
View File
@@ -60,9 +60,10 @@ inline WeatherIconType getWeatherIconType(int wmoCode, bool isDay) {
case 80: case 80:
case 81: case 81:
case 82: case 82:
return WeatherIconType::SHOWERS;
case 85: case 85:
case 86: case 86:
return WeatherIconType::SHOWERS; return WeatherIconType::SNOW;
case 95: case 95:
case 96: case 96:
case 99: case 99:
@@ -72,9 +73,6 @@ inline WeatherIconType getWeatherIconType(int wmoCode, bool isDay) {
} }
} }
// Get human-readable description for WMO weather code
const char* getWeatherDescription(int wmoCode);
// Get the appropriate large icon bitmap (64x64, 1-bit, MSB first) // Get the appropriate large icon bitmap (64x64, 1-bit, MSB first)
const uint8_t* getWeatherIconLarge(WeatherIconType type); const uint8_t* getWeatherIconLarge(WeatherIconType type);
+10
View File
@@ -16,6 +16,7 @@ bool WeatherSettingsStore::saveToFile() const {
JsonDocument doc; JsonDocument doc;
doc["latitude"] = latitude; doc["latitude"] = latitude;
doc["longitude"] = longitude; doc["longitude"] = longitude;
doc["locationConfigured"] = locationConfigured;
doc["locationName"] = locationName; doc["locationName"] = locationName;
doc["tempUnit"] = static_cast<uint8_t>(tempUnit); doc["tempUnit"] = static_cast<uint8_t>(tempUnit);
doc["windUnit"] = static_cast<uint8_t>(windUnit); doc["windUnit"] = static_cast<uint8_t>(windUnit);
@@ -47,6 +48,7 @@ bool WeatherSettingsStore::loadFromFile() {
latitude = doc["latitude"] | 0.0f; latitude = doc["latitude"] | 0.0f;
longitude = doc["longitude"] | 0.0f; longitude = doc["longitude"] | 0.0f;
locationConfigured = doc["locationConfigured"] | (latitude != 0.0f || longitude != 0.0f);
locationName = doc["locationName"] | std::string(""); locationName = doc["locationName"] | std::string("");
tempUnit = static_cast<WeatherTempUnit>(doc["tempUnit"] | (uint8_t)0); tempUnit = static_cast<WeatherTempUnit>(doc["tempUnit"] | (uint8_t)0);
windUnit = static_cast<WeatherWindUnit>(doc["windUnit"] | (uint8_t)0); windUnit = static_cast<WeatherWindUnit>(doc["windUnit"] | (uint8_t)0);
@@ -63,10 +65,18 @@ bool WeatherSettingsStore::loadFromFile() {
void WeatherSettingsStore::setLocation(float lat, float lon, const std::string& name) { void WeatherSettingsStore::setLocation(float lat, float lon, const std::string& name) {
latitude = lat; latitude = lat;
longitude = lon; longitude = lon;
locationConfigured = true;
locationName = name; locationName = name;
LOG_DBG("WEA", "Set location: %s (%.4f, %.4f)", name.c_str(), lat, lon); LOG_DBG("WEA", "Set location: %s (%.4f, %.4f)", name.c_str(), lat, lon);
} }
void WeatherSettingsStore::clearLocation() {
latitude = 0;
longitude = 0;
locationConfigured = false;
locationName.clear();
}
void WeatherSettingsStore::setForecastDays(uint8_t days) { void WeatherSettingsStore::setForecastDays(uint8_t days) {
if (days < 1) days = 1; if (days < 1) days = 1;
if (days > 5) days = 5; if (days > 5) days = 5;
+3 -1
View File
@@ -12,6 +12,7 @@ class WeatherSettingsStore {
float latitude = 0; float latitude = 0;
float longitude = 0; float longitude = 0;
bool locationConfigured = false;
std::string locationName; std::string locationName;
WeatherTempUnit tempUnit = WeatherTempUnit::CELSIUS; WeatherTempUnit tempUnit = WeatherTempUnit::CELSIUS;
WeatherWindUnit windUnit = WeatherWindUnit::KMH; WeatherWindUnit windUnit = WeatherWindUnit::KMH;
@@ -31,10 +32,11 @@ class WeatherSettingsStore {
// Location // Location
void setLocation(float lat, float lon, const std::string& name); void setLocation(float lat, float lon, const std::string& name);
void clearLocation();
float getLatitude() const { return latitude; } float getLatitude() const { return latitude; }
float getLongitude() const { return longitude; } float getLongitude() const { return longitude; }
const std::string& getLocationName() const { return locationName; } const std::string& getLocationName() const { return locationName; }
bool hasLocation() const { return latitude != 0 || longitude != 0; } bool hasLocation() const { return locationConfigured; }
// Units // Units
void setTempUnit(WeatherTempUnit unit) { tempUnit = unit; } void setTempUnit(WeatherTempUnit unit) { tempUnit = unit; }
+3 -55
View File
@@ -3,63 +3,11 @@ import os
from PIL import Image from PIL import Image
import cairosvg import cairosvg
import io import io
import xml.etree.ElementTree as ET
from svg_utils import fit_inside_canvas, parse_svg_intrinsic_size
threshold = 128 threshold = 128
def parse_svg_intrinsic_size(svg_data):
try:
root = ET.fromstring(svg_data)
except ET.ParseError:
return None, None
viewbox = root.get("viewBox") or root.get("viewbox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) == 4:
try:
vb_w = float(parts[2])
vb_h = float(parts[3])
if vb_w > 0 and vb_h > 0:
return vb_w, vb_h
except ValueError:
pass
def parse_len(value):
if not value:
return None
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in ".-")
if not cleaned:
return None
try:
parsed = float(cleaned)
return parsed if parsed > 0 else None
except ValueError:
return None
w = parse_len(root.get("width"))
h = parse_len(root.get("height"))
return w, h
def fit_inside_canvas(src_w, src_h, dst_w, dst_h):
if src_w <= 0 or src_h <= 0 or dst_w <= 0 or dst_h <= 0:
return dst_w, dst_h
src_ratio = src_w / src_h
dst_ratio = dst_w / dst_h
if src_ratio >= dst_ratio:
fit_w = dst_w
fit_h = max(1, int(round(fit_w / src_ratio)))
else:
fit_h = dst_h
fit_w = max(1, int(round(fit_h * src_ratio)))
return fit_w, fit_h
def svg_to_png_bytes(svg_path, width, height): def svg_to_png_bytes(svg_path, width, height):
with open(svg_path, "rb") as f: with open(svg_path, "rb") as f:
svg_data = f.read() svg_data = f.read()
@@ -124,7 +72,7 @@ def image_to_c_array(img, array_name):
byte |= bit << (7 - b) byte |= bit << (7 - b)
packed.append(byte) packed.append(byte)
# Format as C array # Format as C array
c = f"#pragma once\n#include <cstdint>\n\n" c = "#pragma once\n#include <cstdint>\n\n"
c += f"// size: {width}x{height}\n" c += f"// size: {width}x{height}\n"
c += f"static const uint8_t {array_name}[] = {{\n " c += f"static const uint8_t {array_name}[] = {{\n "
for i, v in enumerate(packed): for i, v in enumerate(packed):
+3 -51
View File
@@ -14,14 +14,15 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
import urllib.request import urllib.request
import xml.etree.ElementTree as ET
import zipfile import zipfile
from PIL import Image from PIL import Image
from svg_utils import fit_inside_canvas, parse_svg_intrinsic_size
try: try:
import cairosvg # type: ignore import cairosvg # type: ignore
except Exception: except ImportError:
cairosvg = None cairosvg = None
@@ -52,55 +53,6 @@ ICON_SOURCES = {
"WI48_THUNDERSTORM": "wi-thunderstorm.svg", "WI48_THUNDERSTORM": "wi-thunderstorm.svg",
} }
def parse_svg_intrinsic_size(svg_data):
try:
root = ET.fromstring(svg_data)
except ET.ParseError:
return None, None
viewbox = root.get("viewBox") or root.get("viewbox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) == 4:
try:
vb_w = float(parts[2])
vb_h = float(parts[3])
if vb_w > 0 and vb_h > 0:
return vb_w, vb_h
except ValueError:
pass
def parse_len(value):
if not value:
return None
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in ".-")
if not cleaned:
return None
try:
parsed = float(cleaned)
return parsed if parsed > 0 else None
except ValueError:
return None
return parse_len(root.get("width")), parse_len(root.get("height"))
def fit_inside_canvas(src_w, src_h, dst_w, dst_h):
if src_w <= 0 or src_h <= 0:
return dst_w, dst_h
src_ratio = src_w / src_h
dst_ratio = dst_w / dst_h
if src_ratio >= dst_ratio:
fit_w = dst_w
fit_h = max(1, int(round(fit_w / src_ratio)))
else:
fit_h = dst_h
fit_w = max(1, int(round(fit_h * src_ratio)))
return fit_w, fit_h
def ensure_resvg_binary(): def ensure_resvg_binary():
if shutil.which("resvg"): if shutil.which("resvg"):
return Path(shutil.which("resvg")) return Path(shutil.which("resvg"))
+53
View File
@@ -0,0 +1,53 @@
import xml.etree.ElementTree as ET
def parse_svg_intrinsic_size(svg_data):
try:
root = ET.fromstring(svg_data)
except ET.ParseError:
return None, None
viewbox = root.get("viewBox") or root.get("viewbox")
if viewbox:
parts = viewbox.replace(",", " ").split()
if len(parts) == 4:
try:
vb_w = float(parts[2])
vb_h = float(parts[3])
if vb_w > 0 and vb_h > 0:
return vb_w, vb_h
except ValueError:
pass
def parse_len(value):
if not value:
return None
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in ".-")
if not cleaned:
return None
try:
parsed = float(cleaned)
return parsed if parsed > 0 else None
except ValueError:
return None
w = parse_len(root.get("width"))
h = parse_len(root.get("height"))
return w, h
def fit_inside_canvas(src_w, src_h, dst_w, dst_h):
if src_w <= 0 or src_h <= 0 or dst_w <= 0 or dst_h <= 0:
return dst_w, dst_h
src_ratio = src_w / src_h
dst_ratio = dst_w / dst_h
if src_ratio >= dst_ratio:
fit_w = dst_w
fit_h = max(1, round(fit_w / src_ratio))
else:
fit_h = dst_h
fit_w = max(1, round(fit_h * src_ratio))
return fit_w, fit_h
+1 -1
View File
@@ -230,7 +230,7 @@ void HomeActivity::render(RenderLock&&) {
// Build menu items dynamically // Build menu items dynamically
std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER), std::vector<const char*> menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
tr(STR_WEATHER), tr(STR_SETTINGS_TITLE)}; tr(STR_WEATHER), tr(STR_SETTINGS_TITLE)};
std::vector<UIIcon> menuIcons = {Folder, Recent, Library, Transfer, Settings}; std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Library, Settings};
if (hasOpdsUrl) { if (hasOpdsUrl) {
// Insert OPDS Browser after Recents (before File Transfer) // Insert OPDS Browser after Recents (before File Transfer)
+34 -40
View File
@@ -232,6 +232,27 @@ void WeatherActivity::fetchWeather() {
requestUpdate(); requestUpdate();
} }
void WeatherActivity::openSettingsActivity() {
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
startActivityForResult(std::make_unique<WeatherSettingsActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
forceRefresh = true;
loadAndDisplay();
});
}
void WeatherActivity::triggerRefresh(const bool showPopup) {
showRefreshPopup = showPopup;
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = State::FETCHING;
requestUpdate(true);
fetchWeather();
} else {
launchWifiSelection();
}
}
void WeatherActivity::loop() { void WeatherActivity::loop() {
if (state == State::WIFI_SELECTION) { if (state == State::WIFI_SELECTION) {
return; // Handled by WifiSelectionActivity return; // Handled by WifiSelectionActivity
@@ -239,24 +260,10 @@ void WeatherActivity::loop() {
if (state == State::ERROR) { if (state == State::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Open weather settings (useful when no location is configured) openSettingsActivity();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
startActivityForResult(std::make_unique<WeatherSettingsActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
forceRefresh = true;
loadAndDisplay();
});
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left) || } else if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) { mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Retry fetch triggerRefresh(false);
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = State::FETCHING;
requestUpdate(true);
fetchWeather();
} else {
launchWifiSelection();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome(); onGoHome();
} }
@@ -299,27 +306,10 @@ void WeatherActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome(); onGoHome();
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Open weather settings openSettingsActivity();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
startActivityForResult(std::make_unique<WeatherSettingsActivity>(renderer, mappedInput),
[this](const ActivityResult&) {
// Re-apply landscape after returning from settings
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
// Refresh after settings change
forceRefresh = true;
loadAndDisplay();
});
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left) || } else if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) { mappedInput.wasReleased(MappedInputManager::Button::Right)) {
// Manual refresh with popup feedback. triggerRefresh(true);
showRefreshPopup = true;
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
state = State::FETCHING;
requestUpdate(true);
fetchWeather();
} else {
launchWifiSelection();
}
} }
} }
@@ -387,9 +377,9 @@ void WeatherActivity::render(RenderLock&&) {
if (state == State::FETCHING && showRefreshPopup) { if (state == State::FETCHING && showRefreshPopup) {
GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
} else {
renderer.displayBuffer();
} }
renderer.displayBuffer();
} }
void WeatherActivity::renderCurrentConditions(int x, int y, int w, int h) { void WeatherActivity::renderCurrentConditions(int x, int y, int w, int h) {
@@ -490,6 +480,11 @@ void WeatherActivity::renderDailyForecast(int x, int y, int w, int h) {
const StrId dayNameIds[] = {StrId::STR_WEATHER_DAY_SUN, StrId::STR_WEATHER_DAY_MON, StrId::STR_WEATHER_DAY_TUE, const StrId dayNameIds[] = {StrId::STR_WEATHER_DAY_SUN, StrId::STR_WEATHER_DAY_MON, StrId::STR_WEATHER_DAY_TUE,
StrId::STR_WEATHER_DAY_WED, StrId::STR_WEATHER_DAY_THU, StrId::STR_WEATHER_DAY_FRI, StrId::STR_WEATHER_DAY_WED, StrId::STR_WEATHER_DAY_THU, StrId::STR_WEATHER_DAY_FRI,
StrId::STR_WEATHER_DAY_SAT}; StrId::STR_WEATHER_DAY_SAT};
const StrId monthNameIds[] = {
StrId::STR_WEATHER_MONTH_JAN, StrId::STR_WEATHER_MONTH_FEB, StrId::STR_WEATHER_MONTH_MAR,
StrId::STR_WEATHER_MONTH_APR, StrId::STR_WEATHER_MONTH_MAY, StrId::STR_WEATHER_MONTH_JUN,
StrId::STR_WEATHER_MONTH_JUL, StrId::STR_WEATHER_MONTH_AUG, StrId::STR_WEATHER_MONTH_SEP,
StrId::STR_WEATHER_MONTH_OCT, StrId::STR_WEATHER_MONTH_NOV, StrId::STR_WEATHER_MONTH_DEC};
const char* unitSuffix = WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F"; const char* unitSuffix = WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F";
int numDays = static_cast<int>(weatherData.daily.size()); int numDays = static_cast<int>(weatherData.daily.size());
@@ -527,8 +522,8 @@ void WeatherActivity::renderDailyForecast(int x, int y, int w, int h) {
// Date (e.g. "Apr 3") - for all days // Date (e.g. "Apr 3") - for all days
char dateBuf[16]; char dateBuf[16];
const char* monthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; const char* monthName = I18N.get(monthNameIds[timeinfo.tm_mon]);
snprintf(dateBuf, sizeof(dateBuf), "%s %d", monthNames[timeinfo.tm_mon], timeinfo.tm_mday); snprintf(dateBuf, sizeof(dateBuf), "%s %d", monthName, timeinfo.tm_mday);
int dateWidth = renderer.getTextWidth(SMALL_FONT_ID, dateBuf); int dateWidth = renderer.getTextWidth(SMALL_FONT_ID, dateBuf);
renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - dateWidth) / 2, textY, dateBuf); renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - dateWidth) / 2, textY, dateBuf);
textY += 18; textY += 18;
@@ -638,7 +633,6 @@ void WeatherActivity::renderHourlyGraph(int x, int y, int w, int h) {
float mid = (tempMax + tempMin) / 2.0f; float mid = (tempMax + tempMin) / 2.0f;
tempMin = mid - 2.5f; tempMin = mid - 2.5f;
tempMax = mid + 2.5f; tempMax = mid + 2.5f;
tempRange = 5.0f;
} }
tempMin -= 1.0f; tempMin -= 1.0f;
tempMax += 1.0f; tempMax += 1.0f;
+2
View File
@@ -43,6 +43,8 @@ class WeatherActivity final : public Activity {
void launchWifiSelection(); void launchWifiSelection();
void onWifiSelectionComplete(bool connected); void onWifiSelectionComplete(bool connected);
void fetchWeather(); void fetchWeather();
void openSettingsActivity();
void triggerRefresh(bool showPopup);
// Render sub-sections (landscape 800x480) // Render sub-sections (landscape 800x480)
void renderCurrentConditions(int x, int y, int w, int h); void renderCurrentConditions(int x, int y, int w, int h);
@@ -125,21 +125,13 @@ void WeatherSettingsActivity::launchCitySearch() {
[this, query = kb.text](const ActivityResult& wifiResult) { [this, query = kb.text](const ActivityResult& wifiResult) {
if (wifiResult.isCancelled) return; if (wifiResult.isCancelled) return;
searchResults = WeatherClient::searchCity(query); searchResults = WeatherClient::searchCity(query);
if (searchResults.empty()) { showingSearchResults = !searchResults.empty();
requestUpdate();
return;
}
showingSearchResults = true;
selectedIndex = 0; selectedIndex = 0;
requestUpdate(); requestUpdate();
}); });
} else { } else {
searchResults = WeatherClient::searchCity(kb.text); searchResults = WeatherClient::searchCity(kb.text);
if (searchResults.empty()) { showingSearchResults = !searchResults.empty();
requestUpdate();
return;
}
showingSearchResults = true;
selectedIndex = 0; selectedIndex = 0;
requestUpdate(); requestUpdate();
} }
@@ -153,10 +145,14 @@ void WeatherSettingsActivity::launchLatitudeEntry() {
[this](const ActivityResult& result) { [this](const ActivityResult& result) {
if (!result.isCancelled) { if (!result.isCancelled) {
const auto& kb = std::get<KeyboardResult>(result.data); const auto& kb = std::get<KeyboardResult>(result.data);
float lat = strtof(kb.text.c_str(), nullptr); char* end = nullptr;
if (lat >= -90.0f && lat <= 90.0f) { const float lat = strtof(kb.text.c_str(), &end);
WEATHER_SETTINGS.setLocation(lat, WEATHER_SETTINGS.getLongitude(), WEATHER_SETTINGS.getLocationName()); if (end != kb.text.c_str() && *end == '\0' && lat >= -90.0f && lat <= 90.0f) {
if (lat != WEATHER_SETTINGS.getLatitude()) {
WEATHER_SETTINGS.setLocation(lat, WEATHER_SETTINGS.getLongitude(), "");
}
WEATHER_SETTINGS.saveToFile(); WEATHER_SETTINGS.saveToFile();
requestUpdate();
} }
} }
}); });
@@ -169,10 +165,14 @@ void WeatherSettingsActivity::launchLongitudeEntry() {
[this](const ActivityResult& result) { [this](const ActivityResult& result) {
if (!result.isCancelled) { if (!result.isCancelled) {
const auto& kb = std::get<KeyboardResult>(result.data); const auto& kb = std::get<KeyboardResult>(result.data);
float lon = strtof(kb.text.c_str(), nullptr); char* end = nullptr;
if (lon >= -180.0f && lon <= 180.0f) { const float lon = strtof(kb.text.c_str(), &end);
WEATHER_SETTINGS.setLocation(WEATHER_SETTINGS.getLatitude(), lon, WEATHER_SETTINGS.getLocationName()); if (end != kb.text.c_str() && *end == '\0' && lon >= -180.0f && lon <= 180.0f) {
if (lon != WEATHER_SETTINGS.getLongitude()) {
WEATHER_SETTINGS.setLocation(WEATHER_SETTINGS.getLatitude(), lon, "");
}
WEATHER_SETTINGS.saveToFile(); WEATHER_SETTINGS.saveToFile();
requestUpdate();
} }
} }
}); });
@@ -30,7 +30,7 @@ class WeatherSettingsActivity final : public Activity {
std::vector<GeocodingResult> searchResults; std::vector<GeocodingResult> searchResults;
bool showingSearchResults = false; bool showingSearchResults = false;
static constexpr int MENU_ITEMS = 6; // Location, Lat, Lon, TempUnit, WindUnit, PrecipUnit static constexpr size_t MENU_ITEMS = 6; // Location, Lat, Lon, TempUnit, WindUnit, PrecipUnit
void handleSelection(); void handleSelection();
void launchCitySearch(); void launchCitySearch();
-30
View File
@@ -55,52 +55,37 @@ class FileWriteStream final : public Stream {
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
std::unique_ptr<NetworkClient> client; std::unique_ptr<NetworkClient> client;
LOG_DBG("HTTP", "fetchUrl[1] start (https=%d)", UrlUtils::isHttpsUrl(url) ? 1 : 0);
if (UrlUtils::isHttpsUrl(url)) { if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new NetworkClientSecure(); auto* secureClient = new NetworkClientSecure();
LOG_DBG("HTTP", "fetchUrl[2] created NetworkClientSecure");
secureClient->setInsecure(); secureClient->setInsecure();
LOG_DBG("HTTP", "fetchUrl[3] setInsecure done");
client.reset(secureClient); client.reset(secureClient);
} else { } else {
client.reset(new NetworkClient()); client.reset(new NetworkClient());
LOG_DBG("HTTP", "fetchUrl[2] created NetworkClient");
} }
HTTPClient http; HTTPClient http;
LOG_DBG("HTTP", "Fetching: %s", url.c_str()); LOG_DBG("HTTP", "Fetching: %s", url.c_str());
LOG_DBG("HTTP", "fetchUrl[4] begin() before");
http.begin(*client, url.c_str()); http.begin(*client, url.c_str());
LOG_DBG("HTTP", "fetchUrl[5] begin() after");
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
LOG_DBG("HTTP", "fetchUrl[6] redirects configured");
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
LOG_DBG("HTTP", "fetchUrl[7] user-agent header added");
// Add Basic HTTP auth if credentials are configured // Add Basic HTTP auth if credentials are configured
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
String encoded = base64::encode(credentials.c_str()); String encoded = base64::encode(credentials.c_str());
http.addHeader("Authorization", "Basic " + encoded); http.addHeader("Authorization", "Basic " + encoded);
LOG_DBG("HTTP", "fetchUrl[8] auth header added");
} }
LOG_DBG("HTTP", "fetchUrl[9] GET() before");
const int httpCode = http.GET(); const int httpCode = http.GET();
LOG_DBG("HTTP", "fetchUrl[10] GET() after code=%d", httpCode);
if (httpCode != HTTP_CODE_OK) { if (httpCode != HTTP_CODE_OK) {
LOG_ERR("HTTP", "Fetch failed: %d", httpCode); LOG_ERR("HTTP", "Fetch failed: %d", httpCode);
LOG_DBG("HTTP", "fetchUrl[11] end() after GET failure");
http.end(); http.end();
return false; return false;
} }
LOG_DBG("HTTP", "fetchUrl[12] writeToStream() before");
http.writeToStream(&outContent); http.writeToStream(&outContent);
LOG_DBG("HTTP", "fetchUrl[13] writeToStream() after");
LOG_DBG("HTTP", "fetchUrl[14] end() before success return");
http.end(); http.end();
LOG_DBG("HTTP", "Fetch success"); LOG_DBG("HTTP", "Fetch success");
@@ -120,41 +105,30 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
ProgressCallback progress) { ProgressCallback progress) {
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
std::unique_ptr<NetworkClient> client; std::unique_ptr<NetworkClient> client;
LOG_DBG("HTTP", "downloadToFile[1] start (https=%d)", UrlUtils::isHttpsUrl(url) ? 1 : 0);
if (UrlUtils::isHttpsUrl(url)) { if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new NetworkClientSecure(); auto* secureClient = new NetworkClientSecure();
LOG_DBG("HTTP", "downloadToFile[2] created NetworkClientSecure");
secureClient->setInsecure(); secureClient->setInsecure();
LOG_DBG("HTTP", "downloadToFile[3] setInsecure done");
client.reset(secureClient); client.reset(secureClient);
} else { } else {
client.reset(new NetworkClient()); client.reset(new NetworkClient());
LOG_DBG("HTTP", "downloadToFile[2] created NetworkClient");
} }
HTTPClient http; HTTPClient http;
LOG_DBG("HTTP", "Downloading: %s", url.c_str()); LOG_DBG("HTTP", "Downloading: %s", url.c_str());
LOG_DBG("HTTP", "Destination: %s", destPath.c_str()); LOG_DBG("HTTP", "Destination: %s", destPath.c_str());
LOG_DBG("HTTP", "downloadToFile[4] begin() before");
http.begin(*client, url.c_str()); http.begin(*client, url.c_str());
LOG_DBG("HTTP", "downloadToFile[5] begin() after");
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
LOG_DBG("HTTP", "downloadToFile[6] redirects configured");
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
LOG_DBG("HTTP", "downloadToFile[7] user-agent header added");
// Add Basic HTTP auth if credentials are configured // Add Basic HTTP auth if credentials are configured
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
String encoded = base64::encode(credentials.c_str()); String encoded = base64::encode(credentials.c_str());
http.addHeader("Authorization", "Basic " + encoded); http.addHeader("Authorization", "Basic " + encoded);
LOG_DBG("HTTP", "downloadToFile[8] auth header added");
} }
LOG_DBG("HTTP", "downloadToFile[9] GET() before");
const int httpCode = http.GET(); const int httpCode = http.GET();
LOG_DBG("HTTP", "downloadToFile[10] GET() after code=%d", httpCode);
if (httpCode != HTTP_CODE_OK) { if (httpCode != HTTP_CODE_OK) {
LOG_ERR("HTTP", "Download failed: %d", httpCode); LOG_ERR("HTTP", "Download failed: %d", httpCode);
http.end(); http.end();
@@ -184,14 +158,10 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
// Let HTTPClient handle chunked decoding and stream body bytes into the file. // Let HTTPClient handle chunked decoding and stream body bytes into the file.
FileWriteStream fileStream(file, contentLength, progress); FileWriteStream fileStream(file, contentLength, progress);
LOG_DBG("HTTP", "downloadToFile[11] writeToStream() before");
const int writeResult = http.writeToStream(&fileStream); const int writeResult = http.writeToStream(&fileStream);
LOG_DBG("HTTP", "downloadToFile[12] writeToStream() after result=%d", writeResult);
file.close(); file.close();
LOG_DBG("HTTP", "downloadToFile[13] file closed");
http.end(); http.end();
LOG_DBG("HTTP", "downloadToFile[14] http end done");
if (writeResult < 0) { if (writeResult < 0) {
LOG_ERR("HTTP", "writeToStream error: %d", writeResult); LOG_ERR("HTTP", "writeToStream error: %d", writeResult);