diff --git a/README.md b/README.md
index b9093ea5..8b4f876b 100644
--- a/README.md
+++ b/README.md
@@ -177,3 +177,7 @@ CrossPoint Reader is **not affiliated with Xteink or any manufacturer of the X4
Huge shoutout to [**diy-esp32-epub-reader** by atomic14](https://github.com/atomic14/diy-esp32-epub-reader), which was a project I took a lot of inspiration from as I
was making CrossPoint.
+
+## Third-Party Attribution
+
+- Weather icon artwork used by the Weather activity is adapted from [weather-icons by Erik Flowers](https://github.com/erikflowers/weather-icons).
diff --git a/assets/weather-icons/svg/wi-cloudy.svg b/assets/weather-icons/svg/wi-cloudy.svg
new file mode 100644
index 00000000..c3eaf41d
--- /dev/null
+++ b/assets/weather-icons/svg/wi-cloudy.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-day-cloudy.svg b/assets/weather-icons/svg/wi-day-cloudy.svg
new file mode 100644
index 00000000..cf63543a
--- /dev/null
+++ b/assets/weather-icons/svg/wi-day-cloudy.svg
@@ -0,0 +1,26 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-day-sunny.svg b/assets/weather-icons/svg/wi-day-sunny.svg
new file mode 100644
index 00000000..eafcd77d
--- /dev/null
+++ b/assets/weather-icons/svg/wi-day-sunny.svg
@@ -0,0 +1,27 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-fog.svg b/assets/weather-icons/svg/wi-fog.svg
new file mode 100644
index 00000000..cf06f8f8
--- /dev/null
+++ b/assets/weather-icons/svg/wi-fog.svg
@@ -0,0 +1,18 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-night-alt-cloudy.svg b/assets/weather-icons/svg/wi-night-alt-cloudy.svg
new file mode 100644
index 00000000..fcd1496d
--- /dev/null
+++ b/assets/weather-icons/svg/wi-night-alt-cloudy.svg
@@ -0,0 +1,19 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-night-clear.svg b/assets/weather-icons/svg/wi-night-clear.svg
new file mode 100644
index 00000000..053b2de9
--- /dev/null
+++ b/assets/weather-icons/svg/wi-night-clear.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-rain.svg b/assets/weather-icons/svg/wi-rain.svg
new file mode 100644
index 00000000..7ecef3fe
--- /dev/null
+++ b/assets/weather-icons/svg/wi-rain.svg
@@ -0,0 +1,23 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-snow.svg b/assets/weather-icons/svg/wi-snow.svg
new file mode 100644
index 00000000..56afebf9
--- /dev/null
+++ b/assets/weather-icons/svg/wi-snow.svg
@@ -0,0 +1,27 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-sprinkle.svg b/assets/weather-icons/svg/wi-sprinkle.svg
new file mode 100644
index 00000000..c26b2075
--- /dev/null
+++ b/assets/weather-icons/svg/wi-sprinkle.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/assets/weather-icons/svg/wi-thunderstorm.svg b/assets/weather-icons/svg/wi-thunderstorm.svg
new file mode 100644
index 00000000..52555ec7
--- /dev/null
+++ b/assets/weather-icons/svg/wi-thunderstorm.svg
@@ -0,0 +1,21 @@
+
+
+
diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml
index 66de5d31..f4665163 100644
--- a/lib/I18n/translations/english.yaml
+++ b/lib/I18n/translations/english.yaml
@@ -338,6 +338,63 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Take screenshot"
STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: "
STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)"
+STR_WEATHER: "Weather"
+STR_WEATHER_LOCATION: "Location"
+STR_WEATHER_NO_LOCATION: "No location set for weather"
+STR_WEATHER_FETCH_FAILED: "Failed to fetch weather"
+STR_WEATHER_SETTINGS: "Weather Settings"
+STR_WEATHER_SETTINGS_SHORT: "Settings"
+STR_WEATHER_REFRESH: "Refresh"
+STR_WEATHER_FEELS_LIKE: "Feels like"
+STR_WEATHER_HUMIDITY: "Humidity"
+STR_WEATHER_WIND: "Wind"
+STR_WEATHER_PRESSURE: "Pressure"
+STR_WEATHER_LAST_UPDATED: "Last updated"
+STR_WEATHER_PRECIP: "Precip."
+STR_WEATHER_PRECIP_UNIT: "Precipitation Unit"
+STR_WEATHER_WIND_UNIT: "Wind Speed Unit"
+STR_WEATHER_TEMP_UNIT: "Temperature Unit"
+STR_WEATHER_48H_FORECAST: "48-Hour Forecast"
+STR_WEATHER_SEARCH_CITY: "Search City"
+STR_WEATHER_LONGITUDE: "Longitude"
+STR_WEATHER_LATITUDE: "Latitude"
+STR_WEATHER_SEARCH_RESULTS: "Search Results"
+STR_WEATHER_DAY_MON: "Mon"
+STR_WEATHER_DAY_TUE: "Tue"
+STR_WEATHER_DAY_WED: "Wed"
+STR_WEATHER_DAY_THU: "Thu"
+STR_WEATHER_DAY_FRI: "Fri"
+STR_WEATHER_DAY_SAT: "Sat"
+STR_WEATHER_DAY_SUN: "Sun"
+STR_WEATHER_DESC_CLEAR_SKY: "Clear sky"
+STR_WEATHER_DESC_MAINLY_CLEAR: "Mainly clear"
+STR_WEATHER_DESC_PARTLY_CLOUDY: "Partly cloudy"
+STR_WEATHER_DESC_OVERCAST: "Overcast"
+STR_WEATHER_DESC_FOG: "Fog"
+STR_WEATHER_DESC_RIME_FOG: "Rime fog"
+STR_WEATHER_DESC_LIGHT_DRIZZLE: "Light drizzle"
+STR_WEATHER_DESC_DRIZZLE: "Drizzle"
+STR_WEATHER_DESC_DENSE_DRIZZLE: "Dense drizzle"
+STR_WEATHER_DESC_FREEZING_DRIZZLE: "Freezing drizzle"
+STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE: "Dense freezing drizzle"
+STR_WEATHER_DESC_SLIGHT_RAIN: "Slight rain"
+STR_WEATHER_DESC_MODERATE_RAIN: "Moderate rain"
+STR_WEATHER_DESC_HEAVY_RAIN: "Heavy rain"
+STR_WEATHER_DESC_FREEZING_RAIN: "Freezing rain"
+STR_WEATHER_DESC_HEAVY_FREEZING_RAIN: "Heavy freezing rain"
+STR_WEATHER_DESC_SLIGHT_SNOW: "Slight snow"
+STR_WEATHER_DESC_MODERATE_SNOW: "Moderate snow"
+STR_WEATHER_DESC_HEAVY_SNOW: "Heavy snow"
+STR_WEATHER_DESC_SNOW_GRAINS: "Snow grains"
+STR_WEATHER_DESC_SLIGHT_SHOWERS: "Slight showers"
+STR_WEATHER_DESC_MODERATE_SHOWERS: "Moderate showers"
+STR_WEATHER_DESC_VIOLENT_SHOWERS: "Violent showers"
+STR_WEATHER_DESC_SNOW_SHOWERS: "Snow showers"
+STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS: "Heavy snow showers"
+STR_WEATHER_DESC_THUNDERSTORM: "Thunderstorm"
+STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Thunderstorm, hail"
+STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Thunderstorm, heavy hail"
+STR_WEATHER_DESC_UNKNOWN: "Unknown"
STR_INFO: "Info"
STR_AUTHOR: "Author"
STR_SERIES: "Series"
diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml
index bd7b7f12..df5f3c71 100644
--- a/lib/I18n/translations/german.yaml
+++ b/lib/I18n/translations/german.yaml
@@ -320,6 +320,66 @@ STR_UPTIME: "Laufzeit"
STR_CHARGING: "Lädt"
STR_GATHERING_DATA: "Daten werden gesammelt..."
STR_READING: "Lese..."
+
+STR_WEATHER: "Wetter"
+STR_WEATHER_LOCATION: "Ort"
+STR_WEATHER_NO_LOCATION: "Kein Ort für Wetter festgelegt"
+STR_WEATHER_FETCH_FAILED: "Wetterdaten konnten nicht geladen werden"
+STR_WEATHER_SETTINGS: "Wettereinstellungen"
+STR_WEATHER_SETTINGS_SHORT: "Einstell."
+STR_WEATHER_REFRESH: "Aktual."
+STR_WEATHER_FEELS_LIKE: "Gefühlt"
+STR_WEATHER_HUMIDITY: "Luftfeuchte"
+STR_WEATHER_WIND: "Wind"
+STR_WEATHER_PRESSURE: "Luftdruck"
+STR_WEATHER_LAST_UPDATED: "Zuletzt aktualisiert"
+STR_WEATHER_PRECIP: "Niederschl."
+STR_WEATHER_PRECIP_UNIT: "Niederschlagseinheit"
+STR_WEATHER_WIND_UNIT: "Windgeschwindigkeitseinheit"
+STR_WEATHER_TEMP_UNIT: "Temperatureinheit"
+STR_WEATHER_48H_FORECAST: "48-Stunden-Vorhersage"
+STR_WEATHER_SEARCH_CITY: "Stadt suchen"
+STR_WEATHER_LONGITUDE: "Längengrad"
+STR_WEATHER_LATITUDE: "Breitengrad"
+STR_WEATHER_SEARCH_RESULTS: "Suchergebnisse"
+
+STR_WEATHER_DAY_MON: "Mo"
+STR_WEATHER_DAY_TUE: "Di"
+STR_WEATHER_DAY_WED: "Mi"
+STR_WEATHER_DAY_THU: "Do"
+STR_WEATHER_DAY_FRI: "Fr"
+STR_WEATHER_DAY_SAT: "Sa"
+STR_WEATHER_DAY_SUN: "So"
+
+STR_WEATHER_DESC_CLEAR_SKY: "Klarer Himmel"
+STR_WEATHER_DESC_MAINLY_CLEAR: "Überwiegend klar"
+STR_WEATHER_DESC_PARTLY_CLOUDY: "Teilweise bewölkt"
+STR_WEATHER_DESC_OVERCAST: "Bedeckt"
+STR_WEATHER_DESC_FOG: "Nebel"
+STR_WEATHER_DESC_RIME_FOG: "Raureifnebel"
+STR_WEATHER_DESC_LIGHT_DRIZZLE: "Leichter Nieselregen"
+STR_WEATHER_DESC_DRIZZLE: "Nieselregen"
+STR_WEATHER_DESC_DENSE_DRIZZLE: "Dichter Nieselregen"
+STR_WEATHER_DESC_FREEZING_DRIZZLE: "Gefrierender Nieselregen"
+STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE: "Dichter gefrierender Nieselregen"
+STR_WEATHER_DESC_SLIGHT_RAIN: "Leichter Regen"
+STR_WEATHER_DESC_MODERATE_RAIN: "Mäßiger Regen"
+STR_WEATHER_DESC_HEAVY_RAIN: "Starker Regen"
+STR_WEATHER_DESC_FREEZING_RAIN: "Gefrierender Regen"
+STR_WEATHER_DESC_HEAVY_FREEZING_RAIN: "Starker gefrierender Regen"
+STR_WEATHER_DESC_SLIGHT_SNOW: "Leichter Schneefall"
+STR_WEATHER_DESC_MODERATE_SNOW: "Mäßiger Schneefall"
+STR_WEATHER_DESC_HEAVY_SNOW: "Starker Schneefall"
+STR_WEATHER_DESC_SNOW_GRAINS: "Schneegriesel"
+STR_WEATHER_DESC_SLIGHT_SHOWERS: "Leichte Schauer"
+STR_WEATHER_DESC_MODERATE_SHOWERS: "Mäßige Schauer"
+STR_WEATHER_DESC_VIOLENT_SHOWERS: "Heftige Schauer"
+STR_WEATHER_DESC_SNOW_SHOWERS: "Schneeschauer"
+STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS: "Starke Schneeschauer"
+STR_WEATHER_DESC_THUNDERSTORM: "Gewitter"
+STR_WEATHER_DESC_THUNDERSTORM_HAIL: "Gewitter mit Hagel"
+STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL: "Gewitter mit starkem Hagel"
+STR_WEATHER_DESC_UNKNOWN: "Unbekannt"
STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Ersatz für ungültiges Inhaltsverzeichnis erstellen"
STR_USE_CLOCK: "Uhr anzeigen"
STR_CLOCK_SETTINGS: "Uhr-Einstellungen"
diff --git a/lib/Weather/WeatherClient.cpp b/lib/Weather/WeatherClient.cpp
new file mode 100644
index 00000000..da2fb4dc
--- /dev/null
+++ b/lib/Weather/WeatherClient.cpp
@@ -0,0 +1,350 @@
+#include "WeatherClient.h"
+
+#include
+#include
+#include
+
+#include
+
+#include "../../src/network/HttpDownloader.h"
+
+namespace {
+constexpr char WEATHER_CACHE_FILE[] = "/.crosspoint/weather_cache.json";
+
+std::string buildForecastUrl(const WeatherSettingsStore& settings) {
+ // Open-Meteo supports plain HTTP. Using HTTP here avoids TLS handshake
+ // 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 += "&longitude=" + std::to_string(settings.getLongitude());
+ url +=
+ "¤t=temperature_2m,relative_humidity_2m,apparent_temperature,"
+ "weather_code,wind_speed_10m,wind_direction_10m,is_day,precipitation,uv_index";
+ url += "&hourly=temperature_2m,precipitation,precipitation_probability,weather_code,is_day";
+ url +=
+ "&daily=temperature_2m_max,temperature_2m_min,weather_code,"
+ "precipitation_sum,sunrise,sunset,uv_index_max";
+ url += "&timezone=auto&timeformat=unixtime";
+ url += std::string("&temperature_unit=") + settings.getTempUnitParam();
+ url += std::string("&wind_speed_unit=") + settings.getWindUnitParam();
+ url += std::string("&precipitation_unit=") + settings.getPrecipUnitParam();
+ url += "&forecast_days=" + std::to_string(settings.getForecastDays());
+ // Request 48 hours of hourly data
+ url += "&forecast_hours=48";
+ return url;
+}
+} // namespace
+
+WeatherData WeatherClient::getWeather(const WeatherSettingsStore& settings, bool forceRefresh) {
+ if (!settings.hasLocation()) {
+ WeatherData data;
+ data.errorMessage = "No location configured";
+ return data;
+ }
+
+ if (!forceRefresh) {
+ WeatherData cached;
+ if (loadCache(cached) && cached.valid) {
+ 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;
+ }
+
+ LOG_DBG("WEA", "No cache available; caller should establish network and force refresh");
+ WeatherData data;
+ data.errorMessage = "No cache";
+ return data;
+ }
+
+ return fetchFromApi(settings);
+}
+
+WeatherData WeatherClient::fetchFromApi(const WeatherSettingsStore& settings) {
+ WeatherData data;
+ std::string url = buildForecastUrl(settings);
+ LOG_DBG("WEA", "fetchFromApi[1] start");
+ LOG_DBG("WEA", "fetchFromApi[2] url length=%zu", url.size());
+ LOG_DBG("WEA", "Fetching weather from API");
+
+ std::string response;
+ LOG_DBG("WEA", "fetchFromApi[3] HttpDownloader::fetchUrl before");
+ if (!HttpDownloader::fetchUrl(url, response)) {
+ data.errorMessage = "Network error";
+ LOG_ERR("WEA", "Failed to fetch weather data");
+ return data;
+ }
+ LOG_DBG("WEA", "fetchFromApi[4] HttpDownloader::fetchUrl after; bytes=%zu", response.size());
+
+ LOG_DBG("WEA", "fetchFromApi[5] parseWeatherJson before");
+ if (!parseWeatherJson(response, data)) {
+ LOG_ERR("WEA", "Failed to parse weather JSON");
+ return data;
+ }
+ LOG_DBG("WEA", "fetchFromApi[6] parseWeatherJson after");
+
+ data.valid = true;
+ time(&data.fetchedAt);
+ saveCache(data);
+ LOG_DBG("WEA", "fetchFromApi[7] Weather data fetched and cached");
+ return data;
+}
+
+bool WeatherClient::parseWeatherJson(const std::string& json, WeatherData& data) {
+ JsonDocument doc;
+ auto error = deserializeJson(doc, json);
+ if (error) {
+ data.errorMessage = "JSON parse error";
+ LOG_ERR("WEA", "JSON parse error: %s", error.c_str());
+ return false;
+ }
+
+ // Check for API error
+ if (doc["error"] | false) {
+ data.errorMessage = doc["reason"] | "API error";
+ LOG_ERR("WEA", "API error: %s", data.errorMessage.c_str());
+ return false;
+ }
+
+ data.timezone = doc["timezone"] | std::string("");
+ data.utcOffsetSeconds = doc["utc_offset_seconds"] | 0;
+
+ // Parse current weather
+ JsonObject current = doc["current"];
+ if (current) {
+ data.current.temperature = current["temperature_2m"] | 0.0f;
+ data.current.apparentTemperature = current["apparent_temperature"] | 0.0f;
+ data.current.humidity = current["relative_humidity_2m"] | 0;
+ data.current.weatherCode = current["weather_code"] | 0;
+ data.current.windSpeed = current["wind_speed_10m"] | 0.0f;
+ data.current.windDirection = current["wind_direction_10m"] | 0;
+ data.current.precipitation = current["precipitation"] | 0.0f;
+ data.current.uvIndex = current["uv_index"] | 0.0f;
+ data.current.isDay = (current["is_day"] | 1) != 0;
+ }
+
+ // Parse daily forecast
+ JsonObject daily = doc["daily"];
+ if (daily) {
+ JsonArray times = daily["time"];
+ JsonArray tempMax = daily["temperature_2m_max"];
+ JsonArray tempMin = daily["temperature_2m_min"];
+ JsonArray codes = daily["weather_code"];
+ JsonArray precip = daily["precipitation_sum"];
+ JsonArray sunrise = daily["sunrise"];
+ JsonArray sunset = daily["sunset"];
+ JsonArray uvMax = daily["uv_index_max"];
+
+ size_t count = times.size();
+ data.daily.reserve(count);
+ for (size_t i = 0; i < count; i++) {
+ DailyForecast day;
+ day.date = times[i] | (time_t)0;
+ day.tempMax = tempMax[i] | 0.0f;
+ day.tempMin = tempMin[i] | 0.0f;
+ day.weatherCode = codes[i] | 0;
+ day.precipSum = precip[i] | 0.0f;
+ day.sunrise = sunrise[i] | (time_t)0;
+ day.sunset = sunset[i] | (time_t)0;
+ day.uvIndexMax = uvMax[i] | 0.0f;
+ data.daily.push_back(day);
+ }
+ }
+
+ // Parse hourly forecast
+ JsonObject hourly = doc["hourly"];
+ if (hourly) {
+ JsonArray times = hourly["time"];
+ JsonArray temp = hourly["temperature_2m"];
+ JsonArray precip = hourly["precipitation"];
+ JsonArray precipProb = hourly["precipitation_probability"];
+ JsonArray codes = hourly["weather_code"];
+ JsonArray isDay = hourly["is_day"];
+
+ size_t count = times.size();
+ data.hourly.reserve(count);
+ for (size_t i = 0; i < count; i++) {
+ HourlyForecast hour;
+ hour.time = times[i] | (time_t)0;
+ hour.temperature = temp[i] | 0.0f;
+ hour.precipitation = precip[i] | 0.0f;
+ hour.precipitationProbability = precipProb[i] | 0;
+ hour.weatherCode = codes[i] | 0;
+ hour.isDay = (isDay[i] | 1) != 0;
+ data.hourly.push_back(hour);
+ }
+ }
+
+ return true;
+}
+
+bool WeatherClient::saveCache(const WeatherData& data) {
+ Storage.mkdir("/.crosspoint");
+
+ JsonDocument doc;
+ doc["fetchedAt"] = data.fetchedAt;
+ doc["timezone"] = data.timezone;
+ doc["utcOffsetSeconds"] = data.utcOffsetSeconds;
+
+ // Current
+ JsonObject cur = doc["current"].to();
+ cur["temperature"] = data.current.temperature;
+ cur["apparentTemperature"] = data.current.apparentTemperature;
+ cur["humidity"] = data.current.humidity;
+ cur["weatherCode"] = data.current.weatherCode;
+ cur["windSpeed"] = data.current.windSpeed;
+ cur["windDirection"] = data.current.windDirection;
+ cur["precipitation"] = data.current.precipitation;
+ cur["uvIndex"] = data.current.uvIndex;
+ cur["isDay"] = data.current.isDay;
+
+ // Daily
+ JsonArray dailyArr = doc["daily"].to();
+ for (const auto& day : data.daily) {
+ JsonObject d = dailyArr.add();
+ d["date"] = day.date;
+ d["tempMax"] = day.tempMax;
+ d["tempMin"] = day.tempMin;
+ d["weatherCode"] = day.weatherCode;
+ d["precipSum"] = day.precipSum;
+ d["uvIndexMax"] = day.uvIndexMax;
+ d["sunrise"] = day.sunrise;
+ d["sunset"] = day.sunset;
+ }
+
+ // Hourly
+ JsonArray hourlyArr = doc["hourly"].to();
+ for (const auto& hour : data.hourly) {
+ JsonObject h = hourlyArr.add();
+ h["time"] = hour.time;
+ h["temperature"] = hour.temperature;
+ h["precipitation"] = hour.precipitation;
+ h["precipProb"] = hour.precipitationProbability;
+ h["weatherCode"] = hour.weatherCode;
+ h["isDay"] = hour.isDay;
+ }
+
+ String json;
+ serializeJson(doc, json);
+ return Storage.writeFile(WEATHER_CACHE_FILE, json);
+}
+
+bool WeatherClient::loadCache(WeatherData& data) {
+ if (!Storage.exists(WEATHER_CACHE_FILE)) {
+ return false;
+ }
+
+ String json = Storage.readFile(WEATHER_CACHE_FILE);
+ if (json.isEmpty()) {
+ return false;
+ }
+
+ JsonDocument doc;
+ auto error = deserializeJson(doc, json);
+ if (error) {
+ LOG_ERR("WEA", "Cache parse error: %s", error.c_str());
+ return false;
+ }
+
+ data.fetchedAt = doc["fetchedAt"] | (time_t)0;
+ data.timezone = doc["timezone"] | std::string("");
+ data.utcOffsetSeconds = doc["utcOffsetSeconds"] | 0;
+
+ // Current
+ JsonObject cur = doc["current"];
+ if (cur) {
+ data.current.temperature = cur["temperature"] | 0.0f;
+ data.current.apparentTemperature = cur["apparentTemperature"] | 0.0f;
+ data.current.humidity = cur["humidity"] | 0;
+ data.current.weatherCode = cur["weatherCode"] | 0;
+ data.current.windSpeed = cur["windSpeed"] | 0.0f;
+ data.current.windDirection = cur["windDirection"] | 0;
+ data.current.precipitation = cur["precipitation"] | 0.0f;
+ data.current.uvIndex = cur["uvIndex"] | 0.0f;
+ data.current.isDay = cur["isDay"] | true;
+ }
+
+ // Daily
+ JsonArray dailyArr = doc["daily"].as();
+ for (JsonObject d : dailyArr) {
+ DailyForecast day;
+ day.date = d["date"] | (time_t)0;
+ day.tempMax = d["tempMax"] | 0.0f;
+ day.tempMin = d["tempMin"] | 0.0f;
+ day.weatherCode = d["weatherCode"] | 0;
+ day.precipSum = d["precipSum"] | 0.0f;
+ day.uvIndexMax = d["uvIndexMax"] | 0.0f;
+ day.sunrise = d["sunrise"] | (time_t)0;
+ day.sunset = d["sunset"] | (time_t)0;
+ data.daily.push_back(day);
+ }
+
+ // Hourly
+ JsonArray hourlyArr = doc["hourly"].as();
+ for (JsonObject h : hourlyArr) {
+ HourlyForecast hour;
+ hour.time = h["time"] | (time_t)0;
+ hour.temperature = h["temperature"] | 0.0f;
+ hour.precipitation = h["precipitation"] | 0.0f;
+ hour.precipitationProbability = h["precipProb"] | 0;
+ hour.weatherCode = h["weatherCode"] | 0;
+ hour.isDay = h["isDay"] | true;
+ data.hourly.push_back(hour);
+ }
+
+ data.valid = true;
+ LOG_DBG("WEA", "Cache loaded, fetchedAt=%ld", (long)data.fetchedAt);
+ return true;
+}
+
+std::vector WeatherClient::searchCity(const std::string& query) {
+ std::vector results;
+
+ std::string url = "https://geocoding-api.open-meteo.com/v1/search?name=";
+ // Simple URL encoding for the query
+ for (char c : query) {
+ if (c == ' ') {
+ url += "%20";
+ } else if (isalnum(c) || c == '-' || c == '_' || c == '.') {
+ url += c;
+ } else {
+ char buf[4];
+ snprintf(buf, sizeof(buf), "%%%02X", (unsigned char)c);
+ url += buf;
+ }
+ }
+ url += "&count=5&language=en";
+
+ std::string response;
+ if (!HttpDownloader::fetchUrl(url, response)) {
+ LOG_ERR("WEA", "Geocoding request failed");
+ return results;
+ }
+
+ JsonDocument doc;
+ auto error = deserializeJson(doc, response);
+ if (error) {
+ LOG_ERR("WEA", "Geocoding parse error: %s", error.c_str());
+ return results;
+ }
+
+ JsonArray arr = doc["results"].as();
+ for (JsonObject obj : arr) {
+ GeocodingResult r;
+ r.name = obj["name"] | std::string("");
+ r.country = obj["country"] | std::string("");
+ r.admin1 = obj["admin1"] | std::string("");
+ r.latitude = obj["latitude"] | 0.0f;
+ r.longitude = obj["longitude"] | 0.0f;
+ results.push_back(r);
+ }
+
+ LOG_DBG("WEA", "Geocoding found %zu results for '%s'", results.size(), query.c_str());
+ return results;
+}
diff --git a/lib/Weather/WeatherClient.h b/lib/Weather/WeatherClient.h
new file mode 100644
index 00000000..f2ae6e19
--- /dev/null
+++ b/lib/Weather/WeatherClient.h
@@ -0,0 +1,24 @@
+#pragma once
+#include
+
+#include "WeatherData.h"
+#include "WeatherSettingsStore.h"
+
+class WeatherClient {
+ public:
+ /// Fetch weather data, using cache if valid (< 30 min old).
+ /// If forceRefresh is true, always fetches from the API.
+ static WeatherData getWeather(const WeatherSettingsStore& settings, bool forceRefresh = false);
+
+ /// Search for cities by name via Open-Meteo geocoding API.
+ /// Returns up to 5 results.
+ static std::vector searchCity(const std::string& query);
+
+ private:
+ static WeatherData fetchFromApi(const WeatherSettingsStore& settings);
+ static bool parseWeatherJson(const std::string& json, WeatherData& data);
+ static bool saveCache(const WeatherData& data);
+ static bool loadCache(WeatherData& data);
+
+ static constexpr int CACHE_TTL_SECONDS = 30 * 60; // 30 minutes
+};
diff --git a/lib/Weather/WeatherData.h b/lib/Weather/WeatherData.h
new file mode 100644
index 00000000..b593865c
--- /dev/null
+++ b/lib/Weather/WeatherData.h
@@ -0,0 +1,56 @@
+#pragma once
+#include
+#include
+#include
+#include
+
+struct CurrentWeather {
+ float temperature = 0;
+ float apparentTemperature = 0;
+ int humidity = 0;
+ int weatherCode = 0;
+ float windSpeed = 0;
+ int windDirection = 0;
+ float precipitation = 0;
+ float uvIndex = 0;
+ bool isDay = true;
+};
+
+struct DailyForecast {
+ time_t date = 0;
+ float tempMax = 0;
+ float tempMin = 0;
+ int weatherCode = 0;
+ float precipSum = 0;
+ float uvIndexMax = 0;
+ time_t sunrise = 0;
+ time_t sunset = 0;
+};
+
+struct HourlyForecast {
+ time_t time = 0;
+ float temperature = 0;
+ float precipitation = 0;
+ int precipitationProbability = 0;
+ int weatherCode = 0;
+ bool isDay = true;
+};
+
+struct WeatherData {
+ CurrentWeather current;
+ std::vector daily;
+ std::vector hourly;
+ std::string timezone;
+ int utcOffsetSeconds = 0;
+ time_t fetchedAt = 0;
+ bool valid = false;
+ std::string errorMessage;
+};
+
+struct GeocodingResult {
+ std::string name;
+ std::string country;
+ std::string admin1; // State/region
+ float latitude = 0;
+ float longitude = 0;
+};
diff --git a/lib/Weather/WeatherIcons.cpp b/lib/Weather/WeatherIcons.cpp
new file mode 100644
index 00000000..64236d7d
--- /dev/null
+++ b/lib/Weather/WeatherIcons.cpp
@@ -0,0 +1,409 @@
+#include "WeatherIcons.h"
+
+#include "WeatherIcons48.h"
+
+// Weather icon glyph source attribution:
+// https://github.com/erikflowers/weather-icons
+// 64x64 monochrome icon bitmaps in this file are adapted from that icon set.
+
+// ============================================================================
+// 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)
+// Each row = 24 pixels = 3 bytes. Total = 24 * 3 = 72 bytes per icon.
+// ============================================================================
+
+#if WEATHER_ENABLE_SMALL_ICONS
+
+// clang-format off
+
+// Small Clear Day - Sun
+static const uint8_t ICON_CLEAR_DAY_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xE7,0xFF, 0xFF,0xE7,0xFF, 0xFF,0xE7,0xFF,
+ 0xF3,0xE7,0xCF, 0xF9,0x00,0x9F, 0xFF,0x00,0xFF, 0xFE,0x00,0x7F,
+ 0xFC,0x00,0x3F, 0xFC,0x00,0x3F, 0xE0,0x00,0x07, 0xE0,0x00,0x07,
+ 0xE0,0x00,0x07, 0xE0,0x00,0x07, 0xFC,0x00,0x3F, 0xFC,0x00,0x3F,
+ 0xFE,0x00,0x7F, 0xFF,0x00,0xFF, 0xF9,0x00,0x9F, 0xF3,0xE7,0xCF,
+ 0xFF,0xE7,0xFF, 0xFF,0xE7,0xFF, 0xFF,0xE7,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Clear Night - Moon
+static const uint8_t ICON_CLEAR_NIGHT_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xF0,0xFF, 0xFF,0xE0,0x7F,
+ 0xFF,0xC0,0x3F, 0xFF,0x80,0x3F, 0xFF,0x80,0x7F, 0xFF,0x00,0xFF,
+ 0xFF,0x01,0xFF, 0xFF,0x03,0xFF, 0xFF,0x03,0xFF, 0xFF,0x03,0xFF,
+ 0xFF,0x03,0xFF, 0xFF,0x03,0xFF, 0xFF,0x01,0xFF, 0xFF,0x00,0xFF,
+ 0xFF,0x80,0x7F, 0xFF,0x80,0x3F, 0xFF,0xC0,0x3F, 0xFF,0xE0,0x7F,
+ 0xFF,0xF0,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Partly Cloudy
+static const uint8_t ICON_PARTLY_CLOUDY_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xE7,0xFF, 0xFB,0xE7,0xDF, 0xFD,0x00,0xBF,
+ 0xFF,0x00,0xFF, 0xFE,0x00,0x7F, 0xFE,0x00,0x7F, 0xFC,0x00,0x3F,
+ 0xFF,0x00,0x7F, 0xFE,0x00,0x3F, 0xFC,0x00,0x1F, 0xF8,0x00,0x0F,
+ 0xF0,0x00,0x0F, 0xF0,0x00,0x07, 0xE0,0x00,0x07, 0xE0,0x00,0x07,
+ 0xE0,0x00,0x07, 0xE0,0x00,0x07, 0xF0,0x00,0x0F, 0xF8,0x00,0x1F,
+ 0xFF,0x00,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Overcast - Cloud
+static const uint8_t ICON_OVERCAST_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+ 0xFF,0xF0,0xFF, 0xFF,0xE0,0x7F, 0xFF,0xC0,0x3F, 0xFF,0x80,0x1F,
+ 0xFF,0x00,0x1F, 0xFE,0x00,0x0F, 0xFC,0x00,0x07, 0xF8,0x00,0x07,
+ 0xF0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03,
+ 0xE0,0x00,0x03, 0xF0,0x00,0x07, 0xF8,0x00,0x0F, 0xFE,0x00,0x7F,
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Rain
+static const uint8_t ICON_RAIN_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xF0,0xFF, 0xFF,0xE0,0x7F,
+ 0xFF,0x80,0x1F, 0xFF,0x00,0x0F, 0xFE,0x00,0x07, 0xFC,0x00,0x07,
+ 0xF0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03,
+ 0xF0,0x00,0x07, 0xF8,0x00,0x0F, 0xFF,0x00,0xFF, 0xFF,0xFF,0xFF,
+ 0xFE,0xF7,0xBF, 0xFD,0xEF,0x7F, 0xFD,0xEF,0x7F, 0xFB,0xDF,0x7F,
+ 0xFB,0xDF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Snow
+static const uint8_t ICON_SNOW_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xF0,0xFF, 0xFF,0xE0,0x7F,
+ 0xFF,0x80,0x1F, 0xFF,0x00,0x0F, 0xFE,0x00,0x07, 0xFC,0x00,0x07,
+ 0xF0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03,
+ 0xF0,0x00,0x07, 0xF8,0x00,0x0F, 0xFF,0x00,0xFF, 0xFF,0xFF,0xFF,
+ 0xFE,0xF7,0xBF, 0xFF,0x7B,0xDF, 0xFE,0xF7,0xBF, 0xFF,0x7B,0xDF,
+ 0xFE,0xF7,0xBF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Thunderstorm
+static const uint8_t ICON_THUNDERSTORM_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xF0,0xFF, 0xFF,0xE0,0x7F,
+ 0xFF,0x80,0x1F, 0xFF,0x00,0x0F, 0xFE,0x00,0x07, 0xFC,0x00,0x07,
+ 0xF0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03, 0xE0,0x00,0x03,
+ 0xF0,0x00,0x07, 0xF8,0x00,0x0F, 0xFF,0x00,0xFF, 0xFF,0xFF,0xFF,
+ 0xFF,0xF1,0xFF, 0xFF,0xE3,0xFF, 0xFF,0xC0,0xFF, 0xFF,0xF1,0xFF,
+ 0xFF,0xE3,0xFF, 0xFF,0xC7,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// Small Fog
+static const uint8_t ICON_FOG_24[] = {
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xF0,0x00,0x0F, 0xE0,0x00,0x07,
+ 0xF0,0x00,0x0F, 0xFF,0xFF,0xFF, 0xF8,0x00,0x1F, 0xF0,0x00,0x0F,
+ 0xF8,0x00,0x1F, 0xFF,0xFF,0xFF, 0xF0,0x00,0x0F, 0xE0,0x00,0x07,
+ 0xF0,0x00,0x0F, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+ 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF, 0xFF,0xFF,0xFF,
+};
+
+// clang-format on
+
+#endif // WEATHER_ENABLE_SMALL_ICONS
+
+// ============================================================================
+// Lookup functions
+// ============================================================================
+
+const uint8_t* getWeatherIconLarge(WeatherIconType type) {
+ switch (type) {
+ case WeatherIconType::CLEAR_DAY:
+ return WI48_CLEAR_DAY;
+ case WeatherIconType::CLEAR_NIGHT:
+ return WI48_CLEAR_NIGHT;
+ case WeatherIconType::PARTLY_CLOUDY_DAY:
+ return WI48_PARTLY_CLOUDY_DAY;
+ case WeatherIconType::PARTLY_CLOUDY_NIGHT:
+ return WI48_PARTLY_CLOUDY_NIGHT;
+ case WeatherIconType::OVERCAST:
+ return WI48_OVERCAST;
+ case WeatherIconType::FOG:
+ return WI48_FOG;
+ case WeatherIconType::DRIZZLE:
+ return WI48_DRIZZLE;
+ case WeatherIconType::RAIN:
+ case WeatherIconType::SHOWERS:
+ return WI48_RAIN;
+ case WeatherIconType::SNOW:
+ return WI48_SNOW;
+ case WeatherIconType::THUNDERSTORM:
+ return WI48_THUNDERSTORM;
+ default:
+ return WI48_OVERCAST;
+ }
+}
+
+#if WEATHER_ENABLE_SMALL_ICONS
+const uint8_t* getWeatherIconSmall(WeatherIconType type) {
+ switch (type) {
+ case WeatherIconType::CLEAR_DAY:
+ return ICON_CLEAR_DAY_24;
+ case WeatherIconType::CLEAR_NIGHT:
+ return ICON_CLEAR_NIGHT_24;
+ case WeatherIconType::PARTLY_CLOUDY_DAY:
+ case WeatherIconType::PARTLY_CLOUDY_NIGHT:
+ return ICON_PARTLY_CLOUDY_24;
+ case WeatherIconType::OVERCAST:
+ return ICON_OVERCAST_24;
+ case WeatherIconType::FOG:
+ return ICON_FOG_24;
+ case WeatherIconType::DRIZZLE:
+ case WeatherIconType::RAIN:
+ case WeatherIconType::SHOWERS:
+ return ICON_RAIN_24;
+ case WeatherIconType::SNOW:
+ return ICON_SNOW_24;
+ case WeatherIconType::THUNDERSTORM:
+ return ICON_THUNDERSTORM_24;
+ default:
+ return ICON_OVERCAST_24;
+ }
+}
+#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) {
+ // Normalize to 0-360
+ degrees = ((degrees % 360) + 360) % 360;
+ if (degrees >= 338 || degrees < 23) return "N";
+ if (degrees < 68) return "NE";
+ if (degrees < 113) return "E";
+ if (degrees < 158) return "SE";
+ if (degrees < 203) return "S";
+ if (degrees < 248) return "SW";
+ if (degrees < 293) return "W";
+ return "NW";
+}
diff --git a/lib/Weather/WeatherIcons.h b/lib/Weather/WeatherIcons.h
new file mode 100644
index 00000000..5e29f2e5
--- /dev/null
+++ b/lib/Weather/WeatherIcons.h
@@ -0,0 +1,87 @@
+#pragma once
+#include
+
+// Set to 1 (for example via build flag -DWEATHER_ENABLE_SMALL_ICONS=1)
+// to include 24x24 weather icon assets and API.
+#ifndef WEATHER_ENABLE_SMALL_ICONS
+#define WEATHER_ENABLE_SMALL_ICONS 0
+#endif
+
+// Weather icon size constants
+constexpr int WEATHER_ICON_LARGE = 64; // Large icon for current weather
+constexpr int WEATHER_ICON_SMALL = 24; // Small icon for daily forecast
+
+// WMO weather code to icon category mapping
+enum class WeatherIconType {
+ CLEAR_DAY,
+ CLEAR_NIGHT,
+ PARTLY_CLOUDY_DAY,
+ PARTLY_CLOUDY_NIGHT,
+ OVERCAST,
+ FOG,
+ DRIZZLE,
+ RAIN,
+ SNOW,
+ SHOWERS,
+ THUNDERSTORM,
+ UNKNOWN
+};
+
+// Map WMO weather code + day/night to icon type
+inline WeatherIconType getWeatherIconType(int wmoCode, bool isDay) {
+ switch (wmoCode) {
+ case 0:
+ return isDay ? WeatherIconType::CLEAR_DAY : WeatherIconType::CLEAR_NIGHT;
+ case 1:
+ case 2:
+ return isDay ? WeatherIconType::PARTLY_CLOUDY_DAY : WeatherIconType::PARTLY_CLOUDY_NIGHT;
+ case 3:
+ return WeatherIconType::OVERCAST;
+ case 45:
+ case 48:
+ return WeatherIconType::FOG;
+ case 51:
+ case 53:
+ case 55:
+ case 56:
+ case 57:
+ return WeatherIconType::DRIZZLE;
+ case 61:
+ case 63:
+ case 65:
+ case 66:
+ case 67:
+ return WeatherIconType::RAIN;
+ case 71:
+ case 73:
+ case 75:
+ case 77:
+ return WeatherIconType::SNOW;
+ case 80:
+ case 81:
+ case 82:
+ case 85:
+ case 86:
+ return WeatherIconType::SHOWERS;
+ case 95:
+ case 96:
+ case 99:
+ return WeatherIconType::THUNDERSTORM;
+ default:
+ return WeatherIconType::UNKNOWN;
+ }
+}
+
+// Get human-readable description for WMO weather code
+const char* getWeatherDescription(int wmoCode);
+
+// Get the appropriate large icon bitmap (64x64, 1-bit, MSB first)
+const uint8_t* getWeatherIconLarge(WeatherIconType type);
+
+// Get the appropriate small icon bitmap (24x24, 1-bit, MSB first)
+#if WEATHER_ENABLE_SMALL_ICONS
+const uint8_t* getWeatherIconSmall(WeatherIconType type);
+#endif
+
+// Wind direction arrow text (N, NE, E, SE, S, SW, W, NW)
+const char* getWindDirectionText(int degrees);
diff --git a/lib/Weather/WeatherIcons48.h b/lib/Weather/WeatherIcons48.h
new file mode 100644
index 00000000..232fbfef
--- /dev/null
+++ b/lib/Weather/WeatherIcons48.h
@@ -0,0 +1,307 @@
+#pragma once
+#include
+
+// Generated from erikflowers/weather-icons SVGs.
+// 48x48, 1-bit, MSB-first, row-major.
+// Regenerate with: python scripts/generate_weather_icons.py --fetch
+// clang-format off
+
+static const uint8_t WI48_CLEAR_DAY[] = {
+ 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFC,
+ 0x3F, 0xFF, 0xE1, 0xFF, 0xFF, 0x83, 0xFF, 0xFC, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x81,
+ 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xE0,
+ 0x3F, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFF, 0xF8, 0x3F, 0xF8, 0x3F,
+ 0xFC, 0x1F, 0xFF, 0xFF, 0xFC, 0x7F, 0xC0, 0x03, 0xFE, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0,
+ 0x01, 0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x3F, 0xFC, 0x07,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF,
+ 0xFF, 0xC1, 0xFF, 0xFF, 0xC0, 0x3F, 0x83, 0xFF, 0xFF, 0xC1, 0xFC, 0x03, 0x80, 0x1F, 0x03, 0xFF, 0xFF, 0xE1, 0xF8,
+ 0x01, 0x00, 0x1F, 0x07, 0xFF, 0xFF, 0xE0, 0xF8, 0x00, 0x00, 0x1F, 0x07, 0xFF, 0xFF, 0xE0, 0xF8, 0x00, 0x80, 0x1F,
+ 0x03, 0xFF, 0xFF, 0xE1, 0xF8, 0x01, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF,
+ 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x81, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0,
+ 0x3F, 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x01, 0x80, 0x1F,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x7F, 0xC0, 0x03, 0xFE, 0x3F, 0xFF, 0xFF, 0xF8, 0x3F, 0xFE,
+ 0x7F, 0xFC, 0x1F, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFF, 0xE0, 0x3F, 0xFF, 0xFF, 0xFC, 0x07,
+ 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0x81,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x83, 0xFF, 0xFC, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0xC7, 0xFF, 0xFC, 0x3F,
+ 0xFF, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_CLEAR_NIGHT[] = {
+ 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0,
+ 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xE0, 0x00, 0x06, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x7E, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0xFE,
+ 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x07, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xFF, 0x01, 0xFF, 0xFF,
+ 0xFF, 0xFE, 0x00, 0x3F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xF8, 0x00,
+ 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xF0, 0x03, 0xFF, 0xFF, 0x80,
+ 0x3F, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF,
+ 0xE0, 0x0F, 0xFF, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF,
+ 0xFF, 0xF0, 0x03, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xFC, 0x00,
+ 0x7F, 0xFF, 0x80, 0x3F, 0xFF, 0xFF, 0xFE, 0x00, 0x1F, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x03, 0xFF, 0x80,
+ 0x7F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x03, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x01, 0x00, 0x7F, 0xFF, 0xFF,
+ 0xFF, 0xF0, 0x00, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00,
+ 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x01, 0x00, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0x00, 0x7F, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0x01, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x01,
+ 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0x80, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0x80, 0x3F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x03, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF8, 0x07, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x07, 0xE0,
+ 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x0F, 0xF0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xF0, 0x03, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0x80, 0x1F, 0xF8, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x3F, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0xFE, 0x00,
+ 0x3F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xFC, 0x00, 0x7F, 0xFE, 0x00, 0x1F, 0xFF, 0xFF, 0xF8, 0x00, 0xFF, 0xFF, 0x00,
+ 0x0F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0x80, 0x01, 0xFF, 0xFF, 0xC0, 0x00, 0x7F, 0xFE,
+ 0x00, 0x03, 0xFF, 0xFF, 0xE0, 0x00, 0x07, 0xC0, 0x00, 0x07, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF,
+ 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0x00,
+ 0x00, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x1F,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_PARTLY_CLOUDY_DAY[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 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, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF9, 0xFF, 0xF8, 0x7F, 0xFC, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xF8, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF,
+ 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0xC0, 0xFF,
+ 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xC3, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0C, 0x00, 0x01,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x0F, 0x80, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x3F, 0xE0, 0x7F, 0xFF, 0xFF,
+ 0xF8, 0x00, 0x00, 0x7F, 0xF0, 0x7F, 0xFF, 0xFF, 0xF0, 0x1F, 0xE0, 0x3F, 0xF8, 0x3F, 0xFF, 0xFF, 0xE0, 0x7F, 0xF8,
+ 0x3F, 0xFC, 0x3F, 0xFF, 0xFF, 0xE0, 0xFF, 0xFC, 0x1F, 0xFC, 0x1F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFE, 0x0F, 0xFE, 0x1F,
+ 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0x0F, 0xFE, 0x1E, 0x00, 0xFF, 0x83, 0xFF, 0xFF, 0x0F, 0xFE, 0x1E, 0x00, 0xFF, 0x87,
+ 0xFF, 0xFF, 0x87, 0xFE, 0x1E, 0x00, 0xFE, 0x07, 0xFF, 0xFF, 0x80, 0x3E, 0x1F, 0xEF, 0xF8, 0x07, 0xFF, 0xFF, 0x80,
+ 0x0C, 0x1F, 0xFF, 0xF0, 0x07, 0xFF, 0xFF, 0x80, 0x04, 0x3F, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xDC, 0x00, 0x3F, 0xFF,
+ 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF, 0x83, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8,
+ 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x71, 0xFF, 0x07, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xF8, 0x70, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x70, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xF8,
+ 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0xFC, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xE0, 0x7F,
+ 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00,
+ 0x07, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_PARTLY_CLOUDY_NIGHT[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 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, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x03,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x01, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0, 0x0F, 0x83, 0xFF, 0xFF, 0xFF,
+ 0xFC, 0x00, 0x00, 0x3F, 0x83, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00,
+ 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0x00, 0x1C, 0x00, 0x7F, 0xC0, 0xFF,
+ 0xFF, 0xFE, 0x01, 0xFF, 0x80, 0x3F, 0xE0, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xE0, 0x1F, 0xE0, 0x7F, 0xFF, 0xFC, 0x0F,
+ 0xFF, 0xF8, 0x1F, 0xF0, 0x3F, 0xFF, 0xF8, 0x1F, 0xFF, 0xFC, 0x0F, 0xF0, 0x0F, 0xFF, 0xF8, 0x3F, 0xFF, 0xFC, 0x0F,
+ 0xF8, 0x01, 0xFF, 0xF0, 0x3F, 0xFF, 0xFE, 0x07, 0xFC, 0x00, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0x07, 0xFE, 0x00, 0xFF,
+ 0xE0, 0x7F, 0xFF, 0xFF, 0x03, 0xFF, 0x81, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x00, 0x1F, 0xC1, 0xFF, 0x00, 0xFF, 0xFF,
+ 0xFF, 0x80, 0x07, 0x81, 0xFC, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0x03, 0xF8, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x00,
+ 0x03, 0xF0, 0x00, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0x07, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xC0, 0x7F,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFC, 0x0F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F,
+ 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x07, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x83,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xC1, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFC, 0x1F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xE0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0,
+ 0x3F, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xF8, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0x80, 0x00, 0x00, 0x00,
+ 0x00, 0x0F, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 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, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_OVERCAST[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFE, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x03, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F,
+ 0xF0, 0x3F, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0x3F, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFE, 0x0F, 0xFF,
+ 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFE, 0x0F, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x7F, 0xFF, 0x0F, 0xFF, 0xFF, 0xFC, 0x01,
+ 0x80, 0x3F, 0xFF, 0x00, 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0x00, 0x1F, 0xFF, 0xF0, 0x3F, 0xFC, 0x0F, 0xFF,
+ 0x80, 0x0F, 0xFF, 0xE0, 0xFF, 0xFE, 0x07, 0xFF, 0x80, 0x07, 0xFF, 0xE0, 0xFF, 0xFF, 0x07, 0xFF, 0xFE, 0x03, 0xFF,
+ 0xC1, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xC3, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC1, 0xFF, 0xC3, 0xFF, 0xFF,
+ 0xC0, 0xFF, 0xFF, 0xE1, 0xFE, 0x03, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xE0, 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x03, 0xFF,
+ 0xF0, 0xF0, 0x07, 0xFF, 0xFF, 0xC0, 0x01, 0xFF, 0xF0, 0xE0, 0x0F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xF0, 0xC0, 0x7F,
+ 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xF0, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xE0, 0x83, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF8, 0x3F, 0xE1, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xC1, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0x83,
+ 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1C, 0x03, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x07, 0x0F, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFE, 0x18, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x18, 0x3F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
+ 0x18, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0x83,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xE0, 0x7F, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF,
+ 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xC0,
+ 0x00, 0x00, 0x00, 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, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_FOG[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0,
+ 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x00, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF0, 0x1F, 0xF0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFC, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFE,
+ 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xFE, 0x07,
+ 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xC0,
+ 0x01, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0xC0, 0x00, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF,
+ 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x3F, 0xFF, 0x07, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFC, 0x1F, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x1F, 0xFE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 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, 0x00, 0x00, 0x00,
+ 0x0F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0x80,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 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, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xE0,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0, 0x00, 0x00, 0x00, 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, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_DRIZZLE[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00,
+ 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x00, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xFF, 0xFC,
+ 0x07, 0xFF, 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF,
+ 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF,
+ 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xC0, 0x0F, 0xFF, 0xFF, 0x80, 0x7F,
+ 0xFF, 0xFF, 0xE0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0,
+ 0x00, 0x3F, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x0F, 0xFF, 0xFF, 0xCF, 0xFF, 0xE0, 0x0F, 0xE0,
+ 0x3F, 0xFF, 0xFF, 0x87, 0xFF, 0xFC, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0x87, 0xFF, 0xFE, 0x03, 0xC0, 0xFF, 0xFF, 0xFF,
+ 0x03, 0xFF, 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0x81, 0x83, 0xFF, 0xFF, 0xFF, 0x03, 0xFF, 0xFF,
+ 0xC1, 0x83, 0xFF, 0xFC, 0xFF, 0x87, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF,
+ 0xF0, 0x7F, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xE0, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xC0,
+ 0x83, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xE0, 0x1F, 0xCF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xF0,
+ 0x3F, 0x87, 0xFF, 0xFF, 0x81, 0xC0, 0xFF, 0xF8, 0x7F, 0x03, 0xFF, 0xFF, 0x03, 0xC0, 0x7F, 0xFF, 0xFE, 0x03, 0xFF,
+ 0xFE, 0x03, 0xE0, 0x3F, 0xFF, 0xFC, 0x01, 0xFF, 0xFC, 0x07, 0xF0, 0x0F, 0xFF, 0xFC, 0x00, 0xFF, 0xF0, 0x0F, 0xF8,
+ 0x01, 0xFF, 0xF8, 0x00, 0xFF, 0x80, 0x0F, 0xFC, 0x01, 0xFF, 0xF0, 0x00, 0x7F, 0x80, 0x3F, 0xFE, 0x01, 0xFF, 0xF0,
+ 0x00, 0x7F, 0x80, 0x7F, 0xFF, 0x81, 0xFF, 0xF0, 0x00, 0x3F, 0x80, 0xFF, 0xFF, 0xE1, 0xFF, 0xF0, 0x00, 0x3F, 0x87,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xF0, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_RAIN[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
+ 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x0F, 0x00, 0x1F, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF,
+ 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF,
+ 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0,
+ 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF,
+ 0xE0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F,
+ 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xE0, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x83,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xE0, 0x07, 0xFF, 0xFC, 0x3F, 0x8F, 0xE1, 0xFF, 0xE0, 0x03, 0xFF, 0xFC, 0x1F, 0x07, 0xC1, 0xFF, 0xC0, 0x03, 0xFF,
+ 0xF8, 0x1F, 0x07, 0xC1, 0xFF, 0xC0, 0x83, 0xFF, 0xF8, 0x1F, 0x07, 0xC1, 0xFF, 0xC1, 0x81, 0xFF, 0xF8, 0x3E, 0x07,
+ 0xC1, 0xFF, 0x81, 0x80, 0xFF, 0xF8, 0x3E, 0x0F, 0x81, 0xFF, 0x01, 0xC0, 0xFF, 0xF0, 0x3E, 0x0F, 0x83, 0xFF, 0x03,
+ 0xE0, 0x3F, 0xF0, 0x3E, 0x0F, 0x83, 0xFC, 0x07, 0xE0, 0x1F, 0xF0, 0x7C, 0x0F, 0x03, 0xF8, 0x07, 0xF0, 0x01, 0xE0,
+ 0x7C, 0x1F, 0x07, 0x80, 0x0F, 0xF8, 0x01, 0xE0, 0x7C, 0x1F, 0x07, 0x80, 0x1F, 0xFC, 0x01, 0xE0, 0xF8, 0x1F, 0x07,
+ 0x80, 0x3F, 0xFF, 0x01, 0xE0, 0xF8, 0x1E, 0x07, 0x80, 0xFF, 0xFF, 0xC1, 0xC0, 0xF8, 0x3E, 0x0F, 0x83, 0xFF, 0xFF,
+ 0xFD, 0xC0, 0xF8, 0x3E, 0x0F, 0xBF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xF0,
+ 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0x7C, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0x78, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xE0, 0xF8, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0x87, 0xE0, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xEF, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_SNOW[] = {
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
+ 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x00, 0x1F, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x00, 0xFF, 0xE0, 0x0F, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF,
+ 0xFC, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF, 0xFE, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF,
+ 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0xFF, 0xF0,
+ 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFF, 0xFF, 0xC0, 0x1F, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF,
+ 0xC0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0xFF, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F,
+ 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F, 0xF0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x0F, 0xE0, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x07, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x83,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x07, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x81, 0xFF, 0xFF, 0xFE, 0x7F,
+ 0xFF, 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0x01, 0xC0, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0x03,
+ 0xC0, 0x7F, 0xFF, 0xFC, 0x1F, 0xFF, 0xFC, 0x07, 0xE0, 0x1F, 0xFF, 0xFC, 0x3F, 0xFF, 0xF8, 0x07, 0xF0, 0x01, 0xFD,
+ 0xFF, 0xFF, 0x9F, 0x80, 0x0F, 0xF8, 0x01, 0xF0, 0xFF, 0xFF, 0x0F, 0x80, 0x1F, 0xFC, 0x01, 0xF0, 0x7F, 0xFE, 0x07,
+ 0x80, 0x3F, 0xFE, 0x01, 0xF0, 0x7F, 0xFE, 0x0F, 0x80, 0xFF, 0xFF, 0x81, 0xF0, 0xFF, 0xFF, 0x0F, 0x83, 0xFF, 0xFF,
+ 0xFD, 0xFD, 0xFF, 0xFF, 0xFF, 0xBF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC,
+ 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xF0, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFE, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x7F, 0xFE,
+ 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x3F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static const uint8_t WI48_THUNDERSTORM[] = {
+ 0xFF, 0xFF, 0xFF, 0xC0, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8,
+ 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x3F,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0x80, 0x1F, 0x80, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xF0, 0x0F, 0xFF, 0xFF, 0xFF,
+ 0xFE, 0x01, 0xFF, 0xF8, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x07, 0xFF, 0xFE, 0x07, 0xFF, 0xFF, 0xFF, 0xFC, 0x0F, 0xFF,
+ 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFF, 0x81, 0xFF,
+ 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC1, 0xFF, 0xFF, 0xFF, 0xF0, 0x3F, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE0,
+ 0x7F, 0xFF, 0xFF, 0xC0, 0x07, 0xFF, 0xFF, 0x80, 0x7F, 0xFF, 0xFF, 0xE0, 0x01, 0xFF, 0xFE, 0x00, 0x7F, 0xFF, 0xFF,
+ 0xE0, 0x00, 0x7F, 0xFC, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x3F, 0xF8, 0x00, 0x7F, 0xFF, 0xFF, 0xE0, 0x00, 0x1F,
+ 0xF0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x0F, 0xE0, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x07, 0xC0, 0x7F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0x81, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC1, 0x03,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xE0, 0x07, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x03, 0xFF, 0x00, 0x3F, 0x87, 0xE1, 0xFF,
+ 0xE0, 0x03, 0xFF, 0x00, 0x3F, 0x07, 0xE1, 0xFF, 0xC0, 0x03, 0xFE, 0x00, 0x7F, 0x07, 0xC0, 0xFF, 0xC0, 0x83, 0xFE,
+ 0x00, 0x7F, 0x07, 0xC1, 0xFF, 0xC1, 0x81, 0xFE, 0x00, 0xFE, 0x07, 0xC1, 0xFF, 0x81, 0xC0, 0xFC, 0x00, 0xFE, 0x0F,
+ 0x81, 0xFF, 0x01, 0xC0, 0x7C, 0x01, 0xFE, 0x0F, 0x81, 0xFE, 0x03, 0xE0, 0x3C, 0x01, 0xFE, 0x0F, 0x83, 0xFC, 0x07,
+ 0xE0, 0x08, 0x03, 0xFC, 0x0F, 0x83, 0xF0, 0x07, 0xF0, 0x00, 0x03, 0xFC, 0x1F, 0x03, 0x80, 0x0F, 0xF8, 0x00, 0x07,
+ 0xFC, 0x1F, 0x07, 0x80, 0x1F, 0xFC, 0x00, 0x07, 0xFC, 0x1F, 0x07, 0x80, 0x3F, 0xFF, 0x00, 0x0F, 0xF8, 0x1F, 0x07,
+ 0x80, 0xFF, 0xFF, 0xC0, 0x1F, 0xF8, 0x3E, 0x07, 0x83, 0xFF, 0xFF, 0xE0, 0x1F, 0xF8, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF,
+ 0xC0, 0x00, 0x38, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x70, 0x3E, 0x0F, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0xF0,
+ 0x7C, 0x0F, 0xFF, 0xFF, 0xFF, 0x80, 0x00, 0xF0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0x80, 0x01, 0xE0, 0x7C, 0x1F, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0x03, 0xE0, 0x7C, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0x03, 0xE0, 0xFE, 0x3F, 0xFF, 0xFF, 0xFF, 0xFE,
+ 0x07, 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0xC0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x0F, 0xC0, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x1F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x3F, 0xC1, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFC, 0x3F, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x7F, 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF,
+ 0x83, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xFF, 0xEF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFB, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
diff --git a/lib/Weather/WeatherSettingsStore.cpp b/lib/Weather/WeatherSettingsStore.cpp
new file mode 100644
index 00000000..52267936
--- /dev/null
+++ b/lib/Weather/WeatherSettingsStore.cpp
@@ -0,0 +1,96 @@
+#include "WeatherSettingsStore.h"
+
+#include
+#include
+#include
+
+WeatherSettingsStore WeatherSettingsStore::instance;
+
+namespace {
+constexpr char WEATHER_SETTINGS_FILE[] = "/.crosspoint/weather_settings.json";
+}
+
+bool WeatherSettingsStore::saveToFile() const {
+ Storage.mkdir("/.crosspoint");
+
+ JsonDocument doc;
+ doc["latitude"] = latitude;
+ doc["longitude"] = longitude;
+ doc["locationName"] = locationName;
+ doc["tempUnit"] = static_cast(tempUnit);
+ doc["windUnit"] = static_cast(windUnit);
+ doc["precipUnit"] = static_cast(precipUnit);
+ doc["forecastDays"] = forecastDays;
+
+ String json;
+ serializeJson(doc, json);
+ return Storage.writeFile(WEATHER_SETTINGS_FILE, json);
+}
+
+bool WeatherSettingsStore::loadFromFile() {
+ if (!Storage.exists(WEATHER_SETTINGS_FILE)) {
+ LOG_DBG("WEA", "No weather settings file found");
+ return false;
+ }
+
+ String json = Storage.readFile(WEATHER_SETTINGS_FILE);
+ if (json.isEmpty()) {
+ return false;
+ }
+
+ JsonDocument doc;
+ auto error = deserializeJson(doc, json);
+ if (error) {
+ LOG_ERR("WEA", "JSON parse error: %s", error.c_str());
+ return false;
+ }
+
+ latitude = doc["latitude"] | 0.0f;
+ longitude = doc["longitude"] | 0.0f;
+ locationName = doc["locationName"] | std::string("");
+ tempUnit = static_cast(doc["tempUnit"] | (uint8_t)0);
+ windUnit = static_cast(doc["windUnit"] | (uint8_t)0);
+ precipUnit = static_cast(doc["precipUnit"] | (uint8_t)0);
+ forecastDays = doc["forecastDays"] | (uint8_t)3;
+
+ if (forecastDays < 1) forecastDays = 1;
+ if (forecastDays > 5) forecastDays = 5;
+
+ LOG_DBG("WEA", "Loaded weather settings: %s (%.2f, %.2f)", locationName.c_str(), latitude, longitude);
+ return true;
+}
+
+void WeatherSettingsStore::setLocation(float lat, float lon, const std::string& name) {
+ latitude = lat;
+ longitude = lon;
+ locationName = name;
+ LOG_DBG("WEA", "Set location: %s (%.4f, %.4f)", name.c_str(), lat, lon);
+}
+
+void WeatherSettingsStore::setForecastDays(uint8_t days) {
+ if (days < 1) days = 1;
+ if (days > 5) days = 5;
+ forecastDays = days;
+}
+
+const char* WeatherSettingsStore::getTempUnitParam() const {
+ return tempUnit == WeatherTempUnit::FAHRENHEIT ? "fahrenheit" : "celsius";
+}
+
+const char* WeatherSettingsStore::getWindUnitParam() const {
+ switch (windUnit) {
+ case WeatherWindUnit::MS:
+ return "ms";
+ case WeatherWindUnit::MPH:
+ return "mph";
+ case WeatherWindUnit::KNOTS:
+ return "kn";
+ case WeatherWindUnit::KMH:
+ default:
+ return "kmh";
+ }
+}
+
+const char* WeatherSettingsStore::getPrecipUnitParam() const {
+ return precipUnit == WeatherPrecipUnit::INCH ? "inch" : "mm";
+}
diff --git a/lib/Weather/WeatherSettingsStore.h b/lib/Weather/WeatherSettingsStore.h
new file mode 100644
index 00000000..50eacfed
--- /dev/null
+++ b/lib/Weather/WeatherSettingsStore.h
@@ -0,0 +1,57 @@
+#pragma once
+#include
+#include
+
+enum class WeatherTempUnit : uint8_t { CELSIUS = 0, FAHRENHEIT = 1 };
+enum class WeatherWindUnit : uint8_t { KMH = 0, MS = 1, MPH = 2, KNOTS = 3 };
+enum class WeatherPrecipUnit : uint8_t { MM = 0, INCH = 1 };
+
+class WeatherSettingsStore {
+ private:
+ static WeatherSettingsStore instance;
+
+ float latitude = 0;
+ float longitude = 0;
+ std::string locationName;
+ WeatherTempUnit tempUnit = WeatherTempUnit::CELSIUS;
+ WeatherWindUnit windUnit = WeatherWindUnit::KMH;
+ WeatherPrecipUnit precipUnit = WeatherPrecipUnit::MM;
+ uint8_t forecastDays = 3;
+
+ WeatherSettingsStore() = default;
+
+ public:
+ WeatherSettingsStore(const WeatherSettingsStore&) = delete;
+ WeatherSettingsStore& operator=(const WeatherSettingsStore&) = delete;
+
+ static WeatherSettingsStore& getInstance() { return instance; }
+
+ bool saveToFile() const;
+ bool loadFromFile();
+
+ // Location
+ void setLocation(float lat, float lon, const std::string& name);
+ float getLatitude() const { return latitude; }
+ float getLongitude() const { return longitude; }
+ const std::string& getLocationName() const { return locationName; }
+ bool hasLocation() const { return latitude != 0 || longitude != 0; }
+
+ // Units
+ void setTempUnit(WeatherTempUnit unit) { tempUnit = unit; }
+ WeatherTempUnit getTempUnit() const { return tempUnit; }
+ void setWindUnit(WeatherWindUnit unit) { windUnit = unit; }
+ WeatherWindUnit getWindUnit() const { return windUnit; }
+ void setPrecipUnit(WeatherPrecipUnit unit) { precipUnit = unit; }
+ WeatherPrecipUnit getPrecipUnit() const { return precipUnit; }
+
+ // Forecast
+ void setForecastDays(uint8_t days);
+ uint8_t getForecastDays() const { return forecastDays; }
+
+ // API parameter strings
+ const char* getTempUnitParam() const;
+ const char* getWindUnitParam() const;
+ const char* getPrecipUnitParam() const;
+};
+
+#define WEATHER_SETTINGS WeatherSettingsStore::getInstance()
diff --git a/scripts/convert_icon.py b/scripts/convert_icon.py
index 56ea7d4b..81585636 100644
--- a/scripts/convert_icon.py
+++ b/scripts/convert_icon.py
@@ -3,36 +3,113 @@ import os
from PIL import Image
import cairosvg
import io
+import xml.etree.ElementTree as ET
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):
- with open(svg_path, 'rb') as f:
+ with open(svg_path, "rb") as f:
svg_data = f.read()
- png_bytes = cairosvg.svg2png(bytestring=svg_data, output_width=width, output_height=height)
+
+ src_w, src_h = parse_svg_intrinsic_size(svg_data)
+ render_w, render_h = fit_inside_canvas(
+ src_w or width, src_h or height, width, height
+ )
+
+ png_bytes = cairosvg.svg2png(
+ bytestring=svg_data, output_width=render_w, output_height=render_h
+ )
return png_bytes
+
+def center_on_canvas(img, width, height):
+ if img.mode != "RGBA":
+ img = img.convert("RGBA")
+ canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
+ x = (width - img.width) // 2
+ y = (height - img.height) // 2
+ canvas.paste(img, (x, y), img)
+ return canvas
+
+
def load_image(path, width, height):
ext = os.path.splitext(path)[1].lower()
- if ext == '.svg':
+ if ext == ".svg":
png_bytes = svg_to_png_bytes(path, width, height)
- img = Image.open(io.BytesIO(png_bytes))
+ img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
+ img = center_on_canvas(img, width, height)
else:
- img = Image.open(path)
- img = img.convert('RGBA')
- img = img.resize((width, height), Image.LANCZOS)
+ img = Image.open(path).convert("RGBA")
+ # Keep source aspect ratio and fit inside requested canvas.
+ fit = img.copy()
+ fit.thumbnail((width, height), Image.LANCZOS)
+ img = center_on_canvas(fit, width, height)
# Flatten alpha: paste on white background
- background = Image.new('RGBA', img.size, (255, 255, 255, 255))
+ background = Image.new("RGBA", img.size, (255, 255, 255, 255))
background.paste(img, mask=img.split()[3])
img = background
# Rotate 90 degrees counterclockwise
img = img.rotate(90, expand=True)
return img
+
def image_to_c_array(img, array_name):
# Convert to grayscale, then threshold to get white=1, black=0
# Convert to grayscale
- img = img.convert('L')
+ img = img.convert("L")
width, height = img.size
pixels = list(img.getdata())
packed = []
@@ -44,37 +121,39 @@ def image_to_c_array(img, array_name):
v = pixels[y * width + x + b]
# 1 for white, 0 for black
bit = 1 if v >= threshold else 0
- byte |= (bit << (7 - b))
+ byte |= bit << (7 - b)
packed.append(byte)
# Format as C array
- c = f'#pragma once\n#include \n\n'
- c += f'// size: {width}x{height}\n'
- c += f'static const uint8_t {array_name}[] = {{\n '
+ c = f"#pragma once\n#include \n\n"
+ c += f"// size: {width}x{height}\n"
+ c += f"static const uint8_t {array_name}[] = {{\n "
for i, v in enumerate(packed):
- c += f'0x{v:02X}, '
+ c += f"0x{v:02X}, "
if (i + 1) % 16 == 0:
- c += '\n '
- c = c.rstrip(', \n') + '\n};\n'
+ c += "\n "
+ c = c.rstrip(", \n") + "\n};\n"
return c
+
def main():
if len(sys.argv) < 5:
- print('Usage: python convert_image.py input.png output_name width height')
+ print("Usage: python convert_image.py input.png output_name width height")
sys.exit(1)
input_path, output_name, width, height = sys.argv[1:5]
- array_name = output_name.capitalize() + 'Icon'
+ array_name = output_name.capitalize() + "Icon"
width, height = int(width), int(height)
img = load_image(input_path, width, height)
c_array = image_to_c_array(img, array_name)
# Always save to src/components/icons/[output_name].h relative to project root
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
- output_dir = os.path.join(project_root, 'src', 'components', 'icons')
+ output_dir = os.path.join(project_root, "src", "components", "icons")
os.makedirs(output_dir, exist_ok=True)
- output_path = os.path.join(output_dir, f'{output_name}.h')
- with open(output_path, 'w') as f:
+ output_path = os.path.join(output_dir, f"{output_name}.h")
+ with open(output_path, "w") as f:
f.write(c_array)
- print(f'Wrote {output_path}')
+ print(f"Wrote {output_path}")
-if __name__ == '__main__':
- main()
\ No newline at end of file
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate_weather_icons.py b/scripts/generate_weather_icons.py
new file mode 100644
index 00000000..51a9b292
--- /dev/null
+++ b/scripts/generate_weather_icons.py
@@ -0,0 +1,280 @@
+#!/usr/bin/env python3
+"""
+Generate lib/Weather/WeatherIcons48.h from SVG sources.
+
+By default, this script loads SVG files from assets/weather-icons/svg.
+Use --fetch to download missing files from erikflowers/weather-icons.
+"""
+
+import argparse
+import io
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import urllib.request
+import xml.etree.ElementTree as ET
+import zipfile
+
+from PIL import Image
+
+try:
+ import cairosvg # type: ignore
+except Exception:
+ cairosvg = None
+
+
+SIZE = 64
+# Higher threshold slightly thickens dark icon strokes after antialiasing.
+THRESHOLD = 160
+# Keep zero margin so rendered glyphs can use the full 48x48 canvas.
+CONTENT_MARGIN = 0
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_SVG_DIR = PROJECT_ROOT / "assets" / "weather-icons" / "svg"
+DEFAULT_OUT = PROJECT_ROOT / "lib" / "Weather" / "WeatherIcons48.h"
+UPSTREAM_BASE = "https://raw.githubusercontent.com/erikflowers/weather-icons/master/svg"
+RESVG_ZIP_URL = (
+ "https://github.com/linebender/resvg/releases/latest/download/resvg-win64.zip"
+)
+RESVG_EXE = PROJECT_ROOT / ".cache" / "resvg" / "resvg.exe"
+
+ICON_SOURCES = {
+ "WI48_CLEAR_DAY": "wi-day-sunny.svg",
+ "WI48_CLEAR_NIGHT": "wi-night-clear.svg",
+ "WI48_PARTLY_CLOUDY_DAY": "wi-day-cloudy.svg",
+ "WI48_PARTLY_CLOUDY_NIGHT": "wi-night-alt-cloudy.svg",
+ "WI48_OVERCAST": "wi-cloudy.svg",
+ "WI48_FOG": "wi-fog.svg",
+ "WI48_DRIZZLE": "wi-sprinkle.svg",
+ "WI48_RAIN": "wi-rain.svg",
+ "WI48_SNOW": "wi-snow.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():
+ if shutil.which("resvg"):
+ return Path(shutil.which("resvg"))
+
+ if RESVG_EXE.exists():
+ return RESVG_EXE
+
+ RESVG_EXE.parent.mkdir(parents=True, exist_ok=True)
+ archive_path = RESVG_EXE.parent / "resvg.zip"
+ with urllib.request.urlopen(RESVG_ZIP_URL) as response:
+ archive_path.write_bytes(response.read())
+
+ with zipfile.ZipFile(archive_path, "r") as zf:
+ zf.extractall(RESVG_EXE.parent)
+
+ found = list(RESVG_EXE.parent.rglob("resvg.exe"))
+ if not found:
+ raise RuntimeError("resvg.exe not found after extraction")
+
+ if found[0] != RESVG_EXE:
+ RESVG_EXE.write_bytes(found[0].read_bytes())
+
+ return RESVG_EXE
+
+
+def render_svg_with_resvg(svg_data, render_w, render_h):
+ exe = ensure_resvg_binary()
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ in_svg = tmp_path / "icon.svg"
+ out_png = tmp_path / "icon.png"
+ in_svg.write_bytes(svg_data)
+
+ cmd = [
+ str(exe),
+ "--width",
+ str(render_w),
+ "--height",
+ str(render_h),
+ str(in_svg),
+ str(out_png),
+ ]
+ subprocess.run(cmd, check=True, capture_output=True)
+ return out_png.read_bytes()
+
+
+def render_svg_contain(svg_data, width, height):
+ src_w, src_h = parse_svg_intrinsic_size(svg_data)
+ render_w, render_h = fit_inside_canvas(
+ src_w or width, src_h or height, width, height
+ )
+
+ # Render larger first so trimming and re-fit preserve detail quality.
+ oversample = 4
+ render_w *= oversample
+ render_h *= oversample
+
+ if cairosvg is not None:
+ png_bytes = cairosvg.svg2png(
+ bytestring=svg_data, output_width=render_w, output_height=render_h
+ )
+ else:
+ png_bytes = render_svg_with_resvg(svg_data, render_w, render_h)
+
+ icon = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
+
+ # Trim transparent/empty margins so symbols use available icon area better.
+ alpha_bbox = icon.split()[3].getbbox()
+ if alpha_bbox is not None:
+ icon = icon.crop(alpha_bbox)
+
+ max_w = max(1, width - 2 * CONTENT_MARGIN)
+ max_h = max(1, height - 2 * CONTENT_MARGIN)
+ icon.thumbnail((max_w, max_h), Image.Resampling.LANCZOS)
+
+ canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
+ off_x = (width - icon.width) // 2
+ off_y = (height - icon.height) // 2
+ canvas.paste(icon, (off_x, off_y), icon)
+
+ # Flatten alpha on white and convert to monochrome-friendly grayscale.
+ flat = Image.new("RGBA", canvas.size, (255, 255, 255, 255))
+ flat.paste(canvas, mask=canvas.split()[3])
+ return flat.convert("L")
+
+
+def image_to_packed_bits(img):
+ width, height = img.size
+ pixels = img.tobytes()
+ packed = []
+ for y in range(height):
+ for x in range(0, width, 8):
+ out = 0
+ for b in range(8):
+ px = x + b
+ lum = pixels[y * width + px] if px < width else 255
+ # 1-bit means white/clear (not drawn), 0-bit means black/drawn.
+ bit = 1 if lum >= THRESHOLD else 0
+ out |= bit << (7 - b)
+ packed.append(out)
+ return packed
+
+
+def format_array(name, data):
+ lines = []
+ per_line = 16
+ for i in range(0, len(data), per_line):
+ chunk = data[i : i + per_line]
+ lines.append(" " + ", ".join(f"0x{v:02X}" for v in chunk) + ",")
+ body = "\n".join(lines)
+ return f"static const uint8_t {name}[] = {{\n{body}\n}};\n"
+
+
+def ensure_svg(path, fetch):
+ if path.exists():
+ return
+ if not fetch:
+ raise FileNotFoundError(f"Missing SVG: {path}")
+
+ path.parent.mkdir(parents=True, exist_ok=True)
+ url = f"{UPSTREAM_BASE}/{path.name}"
+ with urllib.request.urlopen(url) as response:
+ data = response.read()
+ path.write_bytes(data)
+
+
+def generate(svg_dir, output_path, fetch):
+ arrays = []
+ for symbol, filename in ICON_SOURCES.items():
+ svg_path = svg_dir / filename
+ ensure_svg(svg_path, fetch)
+ svg_data = svg_path.read_bytes()
+ img = render_svg_contain(svg_data, SIZE, SIZE)
+ packed = image_to_packed_bits(img)
+ arrays.append(format_array(symbol, packed))
+
+ header = [
+ "#pragma once",
+ "#include ",
+ "",
+ "// Generated from erikflowers/weather-icons SVGs.",
+ "// 48x48, 1-bit, MSB-first, row-major.",
+ "// Regenerate with: python scripts/generate_weather_icons.py --fetch",
+ "// clang-format off",
+ "",
+ ]
+ header.extend(arrays)
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text("\n".join(header) + "\n", encoding="utf-8")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate WeatherIcons48.h from SVG files"
+ )
+ parser.add_argument(
+ "--svg-dir",
+ type=Path,
+ default=DEFAULT_SVG_DIR,
+ help="Directory containing source SVG files",
+ )
+ parser.add_argument(
+ "--output", type=Path, default=DEFAULT_OUT, help="Output header path"
+ )
+ parser.add_argument(
+ "--fetch", action="store_true", help="Fetch missing SVG files from upstream"
+ )
+ args = parser.parse_args()
+
+ generate(args.svg_dir, args.output, args.fetch)
+ rel_out = os.path.relpath(args.output, PROJECT_ROOT)
+ print(f"Wrote {rel_out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp
index f99bcdc1..8e566d3b 100644
--- a/src/activities/ActivityManager.cpp
+++ b/src/activities/ActivityManager.cpp
@@ -14,6 +14,7 @@
#include "reader/ReaderActivity.h"
#include "settings/SettingsActivity.h"
#include "util/FullScreenMessageActivity.h"
+#include "weather/WeatherActivity.h"
void ActivityManager::begin() {
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
@@ -210,6 +211,8 @@ void ActivityManager::goToFullScreenMessage(std::string message, EpdFontFamily::
replaceActivity(std::make_unique(renderer, mappedInput, std::move(message), style));
}
+void ActivityManager::goToWeather() { replaceActivity(std::make_unique(renderer, mappedInput)); }
+
void ActivityManager::goHome() { replaceActivity(std::make_unique(renderer, mappedInput)); }
void ActivityManager::pushActivity(std::unique_ptr&& activity) {
diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h
index bc24c42e..a38e2985 100644
--- a/src/activities/ActivityManager.h
+++ b/src/activities/ActivityManager.h
@@ -86,6 +86,7 @@ class ActivityManager {
void goToSleep();
void goToBoot();
void goToFullScreenMessage(std::string message, EpdFontFamily::Style style = EpdFontFamily::REGULAR);
+ void goToWeather();
void goHome();
// This will move current activity to stack instead of deleting it
diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp
index ab6a2199..8b90f7a2 100644
--- a/src/activities/home/HomeActivity.cpp
+++ b/src/activities/home/HomeActivity.cpp
@@ -20,7 +20,7 @@
#include "fontIds.h"
int HomeActivity::getMenuItemCount() const {
- int count = 4; // File Browser, Recents, File transfer, Settings
+ int count = 5; // File Browser, Recents, File transfer, Weather, Settings
if (!recentBooks.empty()) {
count += recentBooks.size();
}
@@ -192,6 +192,7 @@ void HomeActivity::loop() {
const int recentsIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++;
+ const int weatherIdx = idx++;
const int settingsIdx = idx;
if (selectorIndex < recentBooks.size()) {
@@ -202,6 +203,8 @@ void HomeActivity::loop() {
onRecentsOpen();
} else if (menuSelectedIndex == opdsLibraryIdx) {
onOpdsBrowserOpen();
+ } else if (menuSelectedIndex == weatherIdx) {
+ onWeatherOpen();
} else if (menuSelectedIndex == fileTransferIdx) {
onFileTransferOpen();
} else if (menuSelectedIndex == settingsIdx) {
@@ -226,11 +229,11 @@ void HomeActivity::render(RenderLock&&) {
// Build menu items dynamically
std::vector menuItems = {tr(STR_BROWSE_FILES), tr(STR_MENU_RECENT_BOOKS), tr(STR_FILE_TRANSFER),
- tr(STR_SETTINGS_TITLE)};
- std::vector menuIcons = {Folder, Recent, Transfer, Settings};
+ tr(STR_WEATHER), tr(STR_SETTINGS_TITLE)};
+ std::vector menuIcons = {Folder, Recent, Library, Transfer, Settings};
if (hasOpdsUrl) {
- // Insert OPDS Browser after File Browser
+ // Insert OPDS Browser after Recents (before File Transfer)
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
menuIcons.insert(menuIcons.begin() + 2, Library);
}
@@ -269,3 +272,5 @@ void HomeActivity::onSettingsOpen() { activityManager.goToSettings(); }
void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); }
void HomeActivity::onOpdsBrowserOpen() { activityManager.goToBrowser(); }
+
+void HomeActivity::onWeatherOpen() { activityManager.goToWeather(); }
diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h
index e2b24bea..32d1c075 100644
--- a/src/activities/home/HomeActivity.h
+++ b/src/activities/home/HomeActivity.h
@@ -26,6 +26,7 @@ class HomeActivity final : public Activity {
void onSettingsOpen();
void onFileTransferOpen();
void onOpdsBrowserOpen();
+ void onWeatherOpen();
int getMenuItemCount() const;
bool storeCoverBuffer(); // Store frame buffer for cover image
diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp
index 6f9cdd76..d64ed44f 100644
--- a/src/activities/settings/SettingsActivity.cpp
+++ b/src/activities/settings/SettingsActivity.cpp
@@ -19,6 +19,7 @@
#include "SyncTimeActivity.h"
#include "SystemInformationActivity.h"
#include "activities/network/WifiSelectionActivity.h"
+#include "activities/weather/WeatherSettingsActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -64,6 +65,7 @@ void SettingsActivity::onEnter() {
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
systemSettings.push_back(SettingInfo::Action(StrId::STR_SYSTEM_INFO, SettingAction::SystemInfo));
+ systemSettings.push_back(SettingInfo::Action(StrId::STR_WEATHER_SETTINGS, SettingAction::Weather));
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
// Reset selection to first category
@@ -207,6 +209,9 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::Language:
startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler);
break;
+ case SettingAction::Weather:
+ startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler);
+ break;
case SettingAction::SystemInfo:
startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler);
break;
diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h
index 9ff90794..1a1bf5f6 100644
--- a/src/activities/settings/SettingsActivity.h
+++ b/src/activities/settings/SettingsActivity.h
@@ -25,6 +25,7 @@ enum class SettingAction {
SystemInfo,
DetectTimezone,
SyncTime,
+ Weather,
};
struct SettingInfo {
diff --git a/src/activities/weather/WeatherActivity.cpp b/src/activities/weather/WeatherActivity.cpp
new file mode 100644
index 00000000..a261a36d
--- /dev/null
+++ b/src/activities/weather/WeatherActivity.cpp
@@ -0,0 +1,743 @@
+#include "WeatherActivity.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include "MappedInputManager.h"
+#include "WeatherSettingsActivity.h"
+#include "activities/network/WifiSelectionActivity.h"
+#include "components/UITheme.h"
+#include "fontIds.h"
+
+namespace {
+inline bool getBitmapBit(const uint8_t* bitmap, const int size, const int x, const int y) {
+ const int rowBytes = size / 8;
+ const int idx = y * rowBytes + (x / 8);
+ const uint8_t mask = static_cast(0x80 >> (x % 8));
+ return (bitmap[idx] & mask) != 0;
+}
+
+void drawWeatherIconWithOrientation(const GfxRenderer& renderer, const uint8_t* icon, const int x, const int y,
+ const int size) {
+ for (int srcY = 0; srcY < size; srcY++) {
+ for (int srcX = 0; srcX < size; srcX++) {
+ // 0 bits are black (drawn) in these icon bitmaps.
+ if (getBitmapBit(icon, size, srcX, srcY)) {
+ continue;
+ }
+
+ renderer.drawPixel(x + srcX, y + srcY);
+ }
+ }
+}
+
+StrId getWeatherDescriptionStrId(const int wmoCode) {
+ switch (wmoCode) {
+ case 0:
+ return StrId::STR_WEATHER_DESC_CLEAR_SKY;
+ case 1:
+ return StrId::STR_WEATHER_DESC_MAINLY_CLEAR;
+ case 2:
+ return StrId::STR_WEATHER_DESC_PARTLY_CLOUDY;
+ case 3:
+ return StrId::STR_WEATHER_DESC_OVERCAST;
+ case 45:
+ return StrId::STR_WEATHER_DESC_FOG;
+ case 48:
+ return StrId::STR_WEATHER_DESC_RIME_FOG;
+ case 51:
+ return StrId::STR_WEATHER_DESC_LIGHT_DRIZZLE;
+ case 53:
+ return StrId::STR_WEATHER_DESC_DRIZZLE;
+ case 55:
+ return StrId::STR_WEATHER_DESC_DENSE_DRIZZLE;
+ case 56:
+ return StrId::STR_WEATHER_DESC_FREEZING_DRIZZLE;
+ case 57:
+ return StrId::STR_WEATHER_DESC_DENSE_FREEZING_DRIZZLE;
+ case 61:
+ return StrId::STR_WEATHER_DESC_SLIGHT_RAIN;
+ case 63:
+ return StrId::STR_WEATHER_DESC_MODERATE_RAIN;
+ case 65:
+ return StrId::STR_WEATHER_DESC_HEAVY_RAIN;
+ case 66:
+ return StrId::STR_WEATHER_DESC_FREEZING_RAIN;
+ case 67:
+ return StrId::STR_WEATHER_DESC_HEAVY_FREEZING_RAIN;
+ case 71:
+ return StrId::STR_WEATHER_DESC_SLIGHT_SNOW;
+ case 73:
+ return StrId::STR_WEATHER_DESC_MODERATE_SNOW;
+ case 75:
+ return StrId::STR_WEATHER_DESC_HEAVY_SNOW;
+ case 77:
+ return StrId::STR_WEATHER_DESC_SNOW_GRAINS;
+ case 80:
+ return StrId::STR_WEATHER_DESC_SLIGHT_SHOWERS;
+ case 81:
+ return StrId::STR_WEATHER_DESC_MODERATE_SHOWERS;
+ case 82:
+ return StrId::STR_WEATHER_DESC_VIOLENT_SHOWERS;
+ case 85:
+ return StrId::STR_WEATHER_DESC_SNOW_SHOWERS;
+ case 86:
+ return StrId::STR_WEATHER_DESC_HEAVY_SNOW_SHOWERS;
+ case 95:
+ return StrId::STR_WEATHER_DESC_THUNDERSTORM;
+ case 96:
+ return StrId::STR_WEATHER_DESC_THUNDERSTORM_HAIL;
+ case 99:
+ return StrId::STR_WEATHER_DESC_THUNDERSTORM_HEAVY_HAIL;
+ default:
+ return StrId::STR_WEATHER_DESC_UNKNOWN;
+ }
+}
+} // namespace
+
+void WeatherActivity::onEnter() {
+ Activity::onEnter();
+
+ // Force landscape orientation for weather display
+ renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
+
+ state = State::LOADING_CACHE;
+ errorMessage.clear();
+ forceRefresh = false;
+ showRefreshPopup = false;
+ requestUpdate();
+
+ loadAndDisplay();
+}
+
+void WeatherActivity::onExit() {
+ Activity::onExit();
+
+ // Restore orientation from settings
+ renderer.setOrientation(static_cast(SETTINGS.orientation));
+
+ WiFi.mode(WIFI_OFF);
+}
+
+void WeatherActivity::loadAndDisplay() {
+ constexpr long CACHE_REFRESH_AGE_SECONDS = 30L * 60L;
+
+ if (!WEATHER_SETTINGS.hasLocation()) {
+ state = State::ERROR;
+ errorMessage = tr(STR_WEATHER_NO_LOCATION);
+ requestUpdate();
+ return;
+ }
+
+ // Try cache first
+ WeatherData cached = WeatherClient::getWeather(WEATHER_SETTINGS, forceRefresh);
+ if (cached.valid) {
+ const time_t now = time(nullptr);
+ const long age = (cached.fetchedAt > 0) ? static_cast(now - cached.fetchedAt) : -1;
+ const bool cacheIsStale = (age >= 0 && age > CACHE_REFRESH_AGE_SECONDS) || age < 0;
+ const bool shouldBackgroundRefresh = forceRefresh || cacheIsStale;
+
+ if (cacheIsStale) {
+ LOG_DBG("WEA", "Displayed stale cache (age=%ld s); scheduling refresh", age);
+ } else {
+ LOG_DBG("WEA", "Displayed fresh cache (age=%ld s); skip auto-refresh", age);
+ }
+
+ weatherData = std::move(cached);
+ state = State::WEATHER_DISPLAY;
+ requestUpdate();
+
+ // Refresh only when forced or when cache is stale.
+ if (shouldBackgroundRefresh) {
+ checkAndConnectWifi();
+ }
+ return;
+ }
+
+ // Need fresh data - check WiFi
+ LOG_DBG("WEA", "No cache to display; proceeding to WiFi/network fetch path");
+ checkAndConnectWifi();
+}
+
+void WeatherActivity::checkAndConnectWifi() {
+ if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
+ state = State::FETCHING;
+ requestUpdate(true);
+ fetchWeather();
+ return;
+ }
+
+ launchWifiSelection();
+}
+
+void WeatherActivity::launchWifiSelection() {
+ state = State::WIFI_SELECTION;
+ requestUpdate();
+
+ startActivityForResult(std::make_unique(renderer, mappedInput),
+ [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
+}
+
+void WeatherActivity::onWifiSelectionComplete(bool connected) {
+ // Re-apply landscape after returning from WiFi selection (which uses portrait)
+ renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
+ LOG_DBG("WEA", "onWifiSelectionComplete connected=%d wifiStatus=%d", connected ? 1 : 0, (int)WiFi.status());
+
+ if (connected) {
+ state = State::CHECK_WIFI;
+ wifiWaitStartedAtMs = millis();
+ LOG_DBG("WEA", "state -> CHECK_WIFI (waitStart=%lu)", (unsigned long)wifiWaitStartedAtMs);
+ requestUpdate(true);
+ } else {
+ WiFi.disconnect();
+ WiFi.mode(WIFI_OFF);
+ state = State::ERROR;
+ errorMessage = tr(STR_WIFI_CONN_FAILED);
+ requestUpdate();
+ }
+}
+
+void WeatherActivity::fetchWeather() {
+ LOG_DBG("WEA", "fetchWeather[1] enter status=%d ip=%s", (int)WiFi.status(), WiFi.localIP().toString().c_str());
+ if (WiFi.status() != WL_CONNECTED || WiFi.localIP() == IPAddress(0, 0, 0, 0)) {
+ LOG_ERR("WEA", "fetchWeather[2] WiFi not ready status=%d ip=%s", (int)WiFi.status(),
+ WiFi.localIP().toString().c_str());
+ state = State::ERROR;
+ errorMessage = tr(STR_WIFI_CONN_FAILED);
+ requestUpdate();
+ return;
+ }
+
+ LOG_DBG("WEA", "fetchWeather[3] WeatherClient::getWeather before");
+ weatherData = WeatherClient::getWeather(WEATHER_SETTINGS, true);
+ LOG_DBG("WEA", "fetchWeather[4] WeatherClient::getWeather after valid=%d", weatherData.valid ? 1 : 0);
+ if (weatherData.valid) {
+ state = State::WEATHER_DISPLAY;
+ } else {
+ state = State::ERROR;
+ errorMessage = weatherData.errorMessage.empty() ? tr(STR_WEATHER_FETCH_FAILED) : weatherData.errorMessage.c_str();
+ }
+ showRefreshPopup = false;
+ requestUpdate();
+}
+
+void WeatherActivity::loop() {
+ if (state == State::WIFI_SELECTION) {
+ return; // Handled by WifiSelectionActivity
+ }
+
+ if (state == State::ERROR) {
+ if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
+ // Open weather settings (useful when no location is configured)
+ renderer.setOrientation(GfxRenderer::Orientation::Portrait);
+ startActivityForResult(std::make_unique(renderer, mappedInput),
+ [this](const ActivityResult&) {
+ renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
+ forceRefresh = true;
+ loadAndDisplay();
+ });
+ } else if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
+ mappedInput.wasReleased(MappedInputManager::Button::Right)) {
+ // Retry fetch
+ 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)) {
+ onGoHome();
+ }
+ return;
+ }
+
+ if (state == State::LOADING_CACHE || state == State::CHECK_WIFI || state == State::FETCHING) {
+ if (state == State::CHECK_WIFI) {
+ LOG_DBG("WEA", "loop CHECK_WIFI status=%d ip=%s elapsed=%lu", (int)WiFi.status(),
+ WiFi.localIP().toString().c_str(), (unsigned long)(millis() - wifiWaitStartedAtMs));
+ if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
+ // Give lwIP/sockets a short window to settle after activity handoff.
+ if (wifiWaitStartedAtMs > 0 && millis() - wifiWaitStartedAtMs < 1200) {
+ LOG_DBG("WEA", "loop CHECK_WIFI waiting for settle window");
+ return;
+ }
+ LOG_DBG("WEA", "state CHECK_WIFI -> FETCHING");
+ state = State::FETCHING;
+ requestUpdate(true);
+ fetchWeather();
+ return;
+ }
+
+ if (wifiWaitStartedAtMs > 0 && millis() - wifiWaitStartedAtMs > 15000) {
+ LOG_ERR("WEA", "CHECK_WIFI timeout after %lu ms", (unsigned long)(millis() - wifiWaitStartedAtMs));
+ state = State::ERROR;
+ errorMessage = tr(STR_WIFI_CONN_FAILED);
+ requestUpdate();
+ return;
+ }
+ }
+
+ if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
+ onGoHome();
+ }
+ return;
+ }
+
+ // WEATHER_DISPLAY state
+ if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
+ onGoHome();
+ } else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
+ // Open weather settings
+ renderer.setOrientation(GfxRenderer::Orientation::Portrait);
+ startActivityForResult(std::make_unique(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) ||
+ mappedInput.wasReleased(MappedInputManager::Button::Right)) {
+ // Manual refresh with popup feedback.
+ showRefreshPopup = true;
+ if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
+ state = State::FETCHING;
+ requestUpdate(true);
+ fetchWeather();
+ } else {
+ launchWifiSelection();
+ }
+ }
+}
+
+void WeatherActivity::render(RenderLock&&) {
+ renderer.clearScreen();
+
+ const auto pageWidth = renderer.getScreenWidth(); // 800 in landscape
+ const auto pageHeight = renderer.getScreenHeight(); // 480 in landscape
+
+ if (state == State::LOADING_CACHE || (state == State::FETCHING && (!showRefreshPopup || !weatherData.valid))) {
+ renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2, tr(STR_LOADING));
+ const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
+ GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
+ renderer.displayBuffer();
+ return;
+ }
+
+ if (state == State::ERROR) {
+ renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
+ renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
+ const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_WEATHER_SETTINGS), tr(STR_WEATHER_REFRESH), "");
+ GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
+ renderer.displayBuffer();
+ return;
+ }
+
+ // DISPLAY state - Landscape layout (800x480 logical)
+ //
+ // +------------------+------------------------------------+
+ // | Current weather | Daily forecast cards |
+ // | (icon, temp, | [Day1] [Day2] [Day3] ... |
+ // | details) | |
+ // | leftPanelWidth=220| right panel = contentWidth-220 |
+ // +------------------+------------------------------------+
+ // | |
+ // | hourly temperature + precipitation graph |
+ // | height = graphHeight (currently 240) |
+ // +--------------------------------------------------------+
+ // | [Back] [Settings] [Refresh] Button hints |
+ // +--------------------------------------------------------+
+
+ const auto& metrics = UITheme::getInstance().getMetrics();
+ // In LandscapeClockwise, physical bottom buttons are at the logical LEFT edge
+ const int contentX = metrics.buttonHintsHeight;
+ const int contentWidth = pageWidth - contentX;
+ constexpr int leftPanelWidth = 220;
+ constexpr int graphHeight = 240;
+ const int topSectionHeight = pageHeight - graphHeight;
+
+ // Draw current conditions (left panel, offset by button hints)
+ renderCurrentConditions(contentX, 0, leftPanelWidth, topSectionHeight);
+
+ // Draw daily forecast (right of current conditions)
+ renderDailyForecast(contentX + leftPanelWidth, 0, contentWidth - leftPanelWidth, topSectionHeight);
+
+ // Separator line
+ renderer.drawLine(contentX, topSectionHeight, pageWidth, topSectionHeight);
+
+ // Draw hourly graph
+ renderHourlyGraph(contentX, topSectionHeight + 1, contentWidth, graphHeight - 1);
+
+ // Button hints
+ const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_WEATHER_SETTINGS_SHORT), tr(STR_WEATHER_REFRESH), "");
+ GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
+
+ if (state == State::FETCHING && showRefreshPopup) {
+ GUI.drawPopup(renderer, tr(STR_LOADING_POPUP));
+ }
+
+ renderer.displayBuffer();
+}
+
+void WeatherActivity::renderCurrentConditions(int x, int y, int w, int h) {
+ const auto& cur = weatherData.current;
+
+ // Location name
+ int textY = y + 15;
+ auto locationName = WEATHER_SETTINGS.getLocationName();
+ if (!locationName.empty()) {
+ auto truncated = renderer.truncatedText(UI_10_FONT_ID, locationName.c_str(), w - 10);
+ renderer.drawText(UI_10_FONT_ID, x + 5, textY, truncated.c_str(), true, EpdFontFamily::BOLD);
+ textY += 20;
+ }
+
+ // Last updated time
+ if (weatherData.fetchedAt > 0) {
+ struct tm timeinfo;
+ time_t localTime = weatherData.fetchedAt + weatherData.utcOffsetSeconds;
+ gmtime_r(&localTime, &timeinfo);
+ char timeBuf[32];
+ snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min);
+ renderer.drawText(SMALL_FONT_ID, x + 5, textY, timeBuf);
+ textY += 15;
+ }
+
+ // Weather icon (WEATHER_ICON_LARGE x WEATHER_ICON_LARGE)
+ auto iconType = getWeatherIconType(cur.weatherCode, cur.isDay);
+ const uint8_t* icon = getWeatherIconLarge(iconType);
+ int iconX = x + (w - WEATHER_ICON_LARGE) / 2;
+ drawWeatherIconWithOrientation(renderer, icon, iconX, textY + 2, WEATHER_ICON_LARGE);
+ textY += WEATHER_ICON_LARGE + 5;
+
+ // Temperature (large)
+ char tempBuf[16];
+ const char* unitSuffix = WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F";
+ snprintf(tempBuf, sizeof(tempBuf), "%.1f %s", cur.temperature, unitSuffix);
+ int tempWidth = renderer.getTextWidth(UI_12_FONT_ID, tempBuf, EpdFontFamily::BOLD);
+ renderer.drawText(UI_12_FONT_ID, x + (w - tempWidth) / 2, textY, tempBuf, true, EpdFontFamily::BOLD);
+ textY += 22;
+
+ // Feels like
+ snprintf(tempBuf, sizeof(tempBuf), "%.1f %s", cur.apparentTemperature, unitSuffix);
+ char feelsLikeBuf[48];
+ snprintf(feelsLikeBuf, sizeof(feelsLikeBuf), "%s: %s", tr(STR_WEATHER_FEELS_LIKE), tempBuf);
+ auto feelsText = renderer.truncatedText(SMALL_FONT_ID, feelsLikeBuf, w - 10);
+ int feelsWidth = renderer.getTextWidth(SMALL_FONT_ID, feelsText.c_str());
+ renderer.drawText(SMALL_FONT_ID, x + (w - feelsWidth) / 2, textY, feelsText.c_str());
+ textY += 16;
+
+ // Weather description
+ const char* desc = I18N.get(getWeatherDescriptionStrId(cur.weatherCode));
+ int descWidth = renderer.getTextWidth(SMALL_FONT_ID, desc);
+ renderer.drawText(SMALL_FONT_ID, x + (w - descWidth) / 2, textY, desc);
+ textY += 20;
+
+ // Details grid
+ const char* windDir = getWindDirectionText(cur.windDirection);
+ char detailBuf[64];
+ const auto drawCenteredDetail = [&](const char* text) {
+ auto truncated = renderer.truncatedText(SMALL_FONT_ID, text, w - 10);
+ const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
+ renderer.drawText(SMALL_FONT_ID, x + (w - textWidth) / 2, textY, truncated.c_str());
+ };
+
+ // Wind
+ snprintf(detailBuf, sizeof(detailBuf), "%s: %.0f %s %s", tr(STR_WEATHER_WIND), cur.windSpeed,
+ WEATHER_SETTINGS.getWindUnitParam(), windDir);
+ drawCenteredDetail(detailBuf);
+ textY += 14;
+
+ // Humidity
+ snprintf(detailBuf, sizeof(detailBuf), "%s: %d%%", tr(STR_WEATHER_HUMIDITY), cur.humidity);
+ drawCenteredDetail(detailBuf);
+ textY += 14;
+
+ // Precipitation
+ if (std::fabs(cur.precipitation) < 0.0001f) {
+ snprintf(detailBuf, sizeof(detailBuf), "%s: --", tr(STR_WEATHER_PRECIP));
+ } else {
+ snprintf(detailBuf, sizeof(detailBuf), "%s: %.1f %s", tr(STR_WEATHER_PRECIP), cur.precipitation,
+ WEATHER_SETTINGS.getPrecipUnitParam());
+ }
+ drawCenteredDetail(detailBuf);
+ textY += 14;
+
+ // UV Index
+ snprintf(detailBuf, sizeof(detailBuf), "UV: %.1f", cur.uvIndex);
+ drawCenteredDetail(detailBuf);
+
+ // Vertical separator
+ renderer.drawLine(x + w, y, x + w, y + h);
+}
+
+void WeatherActivity::renderDailyForecast(int x, int y, int w, int h) {
+ if (weatherData.daily.empty()) return;
+
+ // Localized weekday names (indexed by tm_wday: 0=Sun..6=Sat)
+ 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_SAT};
+
+ const char* unitSuffix = WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F";
+ int numDays = static_cast(weatherData.daily.size());
+
+ if (numDays <= 0) return;
+
+ // Derive weekday progression from the first forecast day to avoid timezone
+ // conversion edge cases causing repeated day labels.
+ struct tm firstDayInfo;
+ time_t firstLocalDate = weatherData.daily[0].date + weatherData.utcOffsetSeconds;
+ gmtime_r(&firstLocalDate, &firstDayInfo);
+ const int firstWeekday = firstDayInfo.tm_wday;
+
+ // Keep all day panels evenly split (distribute remainder 1px left-to-right)
+ const int baseDayWidth = w / numDays;
+ const int extraPx = w % numDays;
+
+ int cardX = x;
+
+ for (int i = 0; i < numDays; i++) {
+ const auto& day = weatherData.daily[i];
+ int cardWidth = baseDayWidth + (i < extraPx ? 1 : 0);
+ int textY = y + 10;
+
+ // Day name (localized)
+ struct tm timeinfo;
+ time_t localDate = day.date + weatherData.utcOffsetSeconds;
+ gmtime_r(&localDate, &timeinfo);
+ const int weekdayIndex = (firstWeekday + i) % 7;
+ const char* dayName = I18N.get(dayNameIds[weekdayIndex]);
+
+ int dayNameWidth = renderer.getTextWidth(UI_10_FONT_ID, dayName, EpdFontFamily::BOLD);
+ renderer.drawText(UI_10_FONT_ID, cardX + (cardWidth - dayNameWidth) / 2, textY, dayName, true, EpdFontFamily::BOLD);
+ textY += 20;
+
+ // Date (e.g. "Apr 3") - for all days
+ char dateBuf[16];
+ const char* monthNames[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
+ snprintf(dateBuf, sizeof(dateBuf), "%s %d", monthNames[timeinfo.tm_mon], timeinfo.tm_mday);
+ int dateWidth = renderer.getTextWidth(SMALL_FONT_ID, dateBuf);
+ renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - dateWidth) / 2, textY, dateBuf);
+ textY += 18;
+
+ // Weather icon (WEATHER_ICON_LARGE for all days)
+ auto iconType = getWeatherIconType(day.weatherCode, true);
+ const uint8_t* icon = getWeatherIconLarge(iconType);
+ int iconX = cardX + (cardWidth - WEATHER_ICON_LARGE) / 2;
+ drawWeatherIconWithOrientation(renderer, icon, iconX, textY, WEATHER_ICON_LARGE);
+ textY += WEATHER_ICON_LARGE + 8;
+
+ // Weather description (short)
+ const char* desc = I18N.get(getWeatherDescriptionStrId(day.weatherCode));
+ auto truncDesc = renderer.truncatedText(SMALL_FONT_ID, desc, cardWidth - 8);
+ int descWidth = renderer.getTextWidth(SMALL_FONT_ID, truncDesc.c_str());
+ renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - descWidth) / 2, textY, truncDesc.c_str());
+ textY += 16;
+
+ // High / Low temp with unit
+ char tempBuf[32];
+ snprintf(tempBuf, sizeof(tempBuf), "%.0f / %.0f %s", day.tempMax, day.tempMin, unitSuffix);
+ int tempWidth = renderer.getTextWidth(SMALL_FONT_ID, tempBuf, EpdFontFamily::BOLD);
+ renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - tempWidth) / 2, textY, tempBuf, true, EpdFontFamily::BOLD);
+ textY += 16;
+
+ // Precipitation
+ char precipBuf[24];
+ if (std::fabs(day.precipSum) < 0.0001f) {
+ snprintf(precipBuf, sizeof(precipBuf), "%s: --", tr(STR_WEATHER_PRECIP));
+ } else {
+ snprintf(precipBuf, sizeof(precipBuf), "%s: %.1f %s", tr(STR_WEATHER_PRECIP), day.precipSum,
+ WEATHER_SETTINGS.getPrecipUnitParam());
+ }
+ auto truncPrecip = renderer.truncatedText(SMALL_FONT_ID, precipBuf, cardWidth - 8);
+ int precipWidth = renderer.getTextWidth(SMALL_FONT_ID, truncPrecip.c_str());
+ renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - precipWidth) / 2, textY, truncPrecip.c_str());
+ textY += 16;
+
+ // UV Index
+ char uvBuf[16];
+ snprintf(uvBuf, sizeof(uvBuf), "UV: %.0f", day.uvIndexMax);
+ int uvWidth = renderer.getTextWidth(SMALL_FONT_ID, uvBuf);
+ renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - uvWidth) / 2, textY, uvBuf);
+
+ // Card separator
+ if (i < numDays - 1) {
+ renderer.drawLine(cardX + cardWidth, y, cardX + cardWidth, y + h);
+ }
+
+ cardX += cardWidth;
+ }
+}
+
+void WeatherActivity::renderHourlyGraph(int x, int y, int w, int h) {
+ if (weatherData.hourly.empty()) return;
+
+ // Localized weekday names (indexed by tm_wday: 0=Sun..6=Sat)
+ 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_SAT};
+
+ constexpr int leftMargin = 56; // Space for Y-axis labels with unit suffix
+ constexpr int rightMargin = 10;
+ constexpr int topMargin = 28; // Keep heading clear of top temperature label
+ constexpr int bottomMargin = 25; // Space for X-axis labels
+ constexpr int precipBarMaxHeight = 30;
+
+ const int graphX = x + leftMargin;
+ const int graphY = y + topMargin;
+ const int graphW = w - leftMargin - rightMargin;
+ const int graphH = h - topMargin - bottomMargin - precipBarMaxHeight;
+ const int precipY = graphY + graphH;
+
+ size_t numPoints = weatherData.hourly.size();
+ if (numPoints < 2) return;
+
+ // Find day boundaries in hourly data (start index of each day).
+ std::vector dayStartIndices;
+ dayStartIndices.reserve(8);
+ dayStartIndices.push_back(0);
+ struct tm prevDayInfo;
+ time_t prevLocalTime = weatherData.hourly[0].time + weatherData.utcOffsetSeconds;
+ gmtime_r(&prevLocalTime, &prevDayInfo);
+ for (size_t i = 1; i < numPoints; i++) {
+ struct tm curDayInfo;
+ time_t localTime = weatherData.hourly[i].time + weatherData.utcOffsetSeconds;
+ gmtime_r(&localTime, &curDayInfo);
+ if (curDayInfo.tm_year != prevDayInfo.tm_year || curDayInfo.tm_yday != prevDayInfo.tm_yday) {
+ dayStartIndices.push_back(i);
+ prevDayInfo = curDayInfo;
+ }
+ }
+
+ // Find temperature range
+ float tempMin = weatherData.hourly[0].temperature;
+ float tempMax = tempMin;
+ float maxPrecip = 0;
+ for (const auto& hr : weatherData.hourly) {
+ tempMin = std::min(tempMin, hr.temperature);
+ tempMax = std::max(tempMax, hr.temperature);
+ maxPrecip = std::max(maxPrecip, hr.precipitation);
+ }
+
+ // Add padding to temp range
+ float tempRange = tempMax - tempMin;
+ if (tempRange < 5.0f) {
+ float mid = (tempMax + tempMin) / 2.0f;
+ tempMin = mid - 2.5f;
+ tempMax = mid + 2.5f;
+ tempRange = 5.0f;
+ }
+ tempMin -= 1.0f;
+ tempMax += 1.0f;
+ tempRange = tempMax - tempMin;
+
+ // Draw graph title centered over the graph area.
+ const char* graphTitle = tr(STR_WEATHER_48H_FORECAST);
+ const int graphTitleWidth = renderer.getTextWidth(SMALL_FONT_ID, graphTitle, EpdFontFamily::BOLD);
+ const int graphTitleX = graphX + (graphW - graphTitleWidth) / 2;
+ renderer.drawText(SMALL_FONT_ID, graphTitleX, y + 3, graphTitle, true, EpdFontFamily::BOLD);
+
+ // Draw weekday labels above the chart area at each day start.
+ for (size_t startIdx : dayStartIndices) {
+ struct tm dayInfo;
+ time_t localTime = weatherData.hourly[startIdx].time + weatherData.utcOffsetSeconds;
+ gmtime_r(&localTime, &dayInfo);
+
+ const char* dayName = I18N.get(dayNameIds[dayInfo.tm_wday]);
+ const int dayLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, dayName, EpdFontFamily::BOLD);
+ const int px = graphX + static_cast(startIdx * graphW / (numPoints - 1));
+ int labelX = px + 2;
+ const int maxLabelX = graphX + graphW - dayLabelWidth - 2;
+ if (labelX > maxLabelX) {
+ labelX = maxLabelX;
+ }
+ if (labelX < graphX + 2) {
+ labelX = graphX + 2;
+ }
+ renderer.drawText(SMALL_FONT_ID, labelX, y + 14, dayName, true, EpdFontFamily::BOLD);
+ }
+
+ // Draw Y-axis labels (temperature)
+ float tempStep = tempRange / 4.0f;
+ const char* unitSuffix = WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F";
+ for (int i = 0; i <= 4; i++) {
+ float temp = tempMax - i * tempStep;
+ int labelY = graphY + i * graphH / 4;
+ char label[16];
+ snprintf(label, sizeof(label), "%.0f %s", temp, unitSuffix);
+ renderer.drawText(SMALL_FONT_ID, x + 2, labelY - 4, label);
+ // Grid line (dashed effect using short segments)
+ for (int gx = graphX; gx < graphX + graphW; gx += 8) {
+ renderer.drawPixel(gx, labelY);
+ }
+ }
+
+ // Draw temperature line
+ int prevPx = -1, prevPy = -1;
+ for (size_t i = 0; i < numPoints; i++) {
+ int px = graphX + static_cast(i * graphW / (numPoints - 1));
+ float normalized = (weatherData.hourly[i].temperature - tempMin) / tempRange;
+ int py = graphY + graphH - static_cast(normalized * graphH);
+
+ if (prevPx >= 0) {
+ renderer.drawLine(prevPx, prevPy, px, py, 2, true);
+ }
+ prevPx = px;
+ prevPy = py;
+ }
+
+ // Draw precipitation bars
+ if (maxPrecip > 0) {
+ for (size_t i = 0; i < numPoints; i++) {
+ float precip = weatherData.hourly[i].precipitation;
+ if (precip <= 0) continue;
+
+ int px = graphX + static_cast(i * graphW / (numPoints - 1));
+ int barH = static_cast((precip / maxPrecip) * precipBarMaxHeight);
+ if (barH < 1) barH = 1;
+ int barW = std::max(1, graphW / static_cast(numPoints) - 1);
+ renderer.fillRect(px - barW / 2, precipY + precipBarMaxHeight - barH, barW, barH);
+ }
+ }
+
+ // Draw day separator lines for day boundaries after the first day.
+ for (size_t boundaryIdx = 1; boundaryIdx < dayStartIndices.size(); boundaryIdx++) {
+ const size_t i = dayStartIndices[boundaryIdx];
+ const int px = graphX + static_cast(i * graphW / (numPoints - 1));
+ renderer.drawLine(px, graphY, px, precipY + precipBarMaxHeight);
+ }
+
+ // Draw X-axis labels (every 6 hours)
+ for (size_t i = 0; i < numPoints; i += 6) {
+ int px = graphX + static_cast(i * graphW / (numPoints - 1));
+ struct tm timeinfo;
+ time_t localTime = weatherData.hourly[i].time + weatherData.utcOffsetSeconds;
+ gmtime_r(&localTime, &timeinfo);
+ char label[8];
+ snprintf(label, sizeof(label), "%02d:00", timeinfo.tm_hour);
+ renderer.drawText(SMALL_FONT_ID, px - 12, precipY + precipBarMaxHeight + 3, label);
+
+ // Vertical grid line
+ for (int gy = graphY; gy < precipY + precipBarMaxHeight; gy += 8) {
+ renderer.drawPixel(px, gy);
+ }
+ }
+
+ // Draw axes
+ renderer.drawLine(graphX, graphY, graphX, precipY + precipBarMaxHeight); // Y axis
+ renderer.drawLine(graphX, precipY + precipBarMaxHeight, graphX + graphW,
+ precipY + precipBarMaxHeight); // X axis
+}
diff --git a/src/activities/weather/WeatherActivity.h b/src/activities/weather/WeatherActivity.h
new file mode 100644
index 00000000..e28f51e1
--- /dev/null
+++ b/src/activities/weather/WeatherActivity.h
@@ -0,0 +1,53 @@
+#pragma once
+
+#include
+
+#include
+
+#include "../Activity.h"
+#include "util/ButtonNavigator.h"
+
+/**
+ * Weather forecast display activity.
+ * Renders in landscape mode (800x480) with:
+ * - Current conditions (left panel)
+ * - Daily forecast cards (center)
+ * - 48-hour temperature + precipitation graph (bottom)
+ *
+ * On enter: loads cache -> if stale, connects WiFi -> fetches data -> displays.
+ * Keys: Back = return, Confirm = open settings, Left = refresh.
+ */
+class WeatherActivity final : public Activity {
+ public:
+ enum class State { LOADING_CACHE, CHECK_WIFI, WIFI_SELECTION, FETCHING, WEATHER_DISPLAY, ERROR };
+
+ explicit WeatherActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
+ : Activity("Weather", renderer, mappedInput) {}
+
+ void onEnter() override;
+ void onExit() override;
+ void loop() override;
+ void render(RenderLock&&) override;
+
+ private:
+ ButtonNavigator buttonNavigator;
+ State state = State::LOADING_CACHE;
+ WeatherData weatherData;
+ std::string errorMessage;
+ bool forceRefresh = false;
+ bool showRefreshPopup = false;
+ uint32_t wifiWaitStartedAtMs = 0;
+
+ void loadAndDisplay();
+ void checkAndConnectWifi();
+ void launchWifiSelection();
+ void onWifiSelectionComplete(bool connected);
+ void fetchWeather();
+
+ // Render sub-sections (landscape 800x480)
+ void renderCurrentConditions(int x, int y, int w, int h);
+ void renderDailyForecast(int x, int y, int w, int h);
+ void renderHourlyGraph(int x, int y, int w, int h);
+
+ bool preventAutoSleep() override { return true; }
+};
diff --git a/src/activities/weather/WeatherSettingsActivity.cpp b/src/activities/weather/WeatherSettingsActivity.cpp
new file mode 100644
index 00000000..01491992
--- /dev/null
+++ b/src/activities/weather/WeatherSettingsActivity.cpp
@@ -0,0 +1,290 @@
+#include "WeatherSettingsActivity.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "MappedInputManager.h"
+#include "activities/network/WifiSelectionActivity.h"
+#include "activities/util/KeyboardEntryActivity.h"
+#include "components/UITheme.h"
+#include "fontIds.h"
+
+namespace {
+const StrId menuNames[] = {
+ StrId::STR_WEATHER_LOCATION, // 0: Search city
+ StrId::STR_WEATHER_LATITUDE, // 1: Manual latitude
+ StrId::STR_WEATHER_LONGITUDE, // 2: Manual longitude
+ StrId::STR_WEATHER_TEMP_UNIT, // 3: Temperature unit
+ StrId::STR_WEATHER_WIND_UNIT, // 4: Wind speed unit
+ StrId::STR_WEATHER_PRECIP_UNIT, // 5: Precipitation unit
+};
+} // namespace
+
+void WeatherSettingsActivity::onEnter() {
+ Activity::onEnter();
+ selectedIndex = 0;
+ showingSearchResults = false;
+ requestUpdate();
+}
+
+void WeatherSettingsActivity::onExit() { Activity::onExit(); }
+
+void WeatherSettingsActivity::loop() {
+ if (showingSearchResults) {
+ // Handle search results navigation
+ if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
+ showingSearchResults = false;
+ requestUpdate();
+ return;
+ }
+
+ if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
+ if (!searchResults.empty() && selectedIndex < searchResults.size()) {
+ const auto& result = searchResults[selectedIndex];
+ WEATHER_SETTINGS.setLocation(result.latitude, result.longitude, result.name + ", " + result.country);
+ WEATHER_SETTINGS.saveToFile();
+ showingSearchResults = false;
+ selectedIndex = 0;
+ requestUpdate();
+ }
+ return;
+ }
+
+ buttonNavigator.onNext([this] {
+ selectedIndex = (selectedIndex + 1) % searchResults.size();
+ requestUpdate();
+ });
+
+ buttonNavigator.onPrevious([this] {
+ selectedIndex = (selectedIndex + searchResults.size() - 1) % searchResults.size();
+ requestUpdate();
+ });
+ return;
+ }
+
+ if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
+ finish();
+ return;
+ }
+
+ if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
+ handleSelection();
+ return;
+ }
+
+ buttonNavigator.onNext([this] {
+ selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
+ requestUpdate();
+ });
+
+ buttonNavigator.onPrevious([this] {
+ selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
+ requestUpdate();
+ });
+}
+
+void WeatherSettingsActivity::handleSelection() {
+ switch (selectedIndex) {
+ case 0:
+ launchCitySearch();
+ break;
+ case 1:
+ launchLatitudeEntry();
+ break;
+ case 2:
+ launchLongitudeEntry();
+ break;
+ case 3:
+ toggleTempUnit();
+ break;
+ case 4:
+ toggleWindUnit();
+ break;
+ case 5:
+ togglePrecipUnit();
+ break;
+ }
+}
+
+void WeatherSettingsActivity::launchCitySearch() {
+ startActivityForResult(
+ std::make_unique(renderer, mappedInput, tr(STR_WEATHER_SEARCH_CITY), "", 64, false),
+ [this](const ActivityResult& result) {
+ if (result.isCancelled) return;
+
+ const auto& kb = std::get(result.data);
+ if (kb.text.empty()) return;
+
+ // Need WiFi for geocoding
+ if (WiFi.status() != WL_CONNECTED || WiFi.localIP() == IPAddress(0, 0, 0, 0)) {
+ startActivityForResult(std::make_unique(renderer, mappedInput),
+ [this, query = kb.text](const ActivityResult& wifiResult) {
+ if (wifiResult.isCancelled) return;
+ searchResults = WeatherClient::searchCity(query);
+ if (searchResults.empty()) {
+ requestUpdate();
+ return;
+ }
+ showingSearchResults = true;
+ selectedIndex = 0;
+ requestUpdate();
+ });
+ } else {
+ searchResults = WeatherClient::searchCity(kb.text);
+ if (searchResults.empty()) {
+ requestUpdate();
+ return;
+ }
+ showingSearchResults = true;
+ selectedIndex = 0;
+ requestUpdate();
+ }
+ });
+}
+
+void WeatherSettingsActivity::launchLatitudeEntry() {
+ std::string current = std::to_string(WEATHER_SETTINGS.getLatitude());
+ startActivityForResult(
+ std::make_unique(renderer, mappedInput, tr(STR_WEATHER_LATITUDE), current, 12, false),
+ [this](const ActivityResult& result) {
+ if (!result.isCancelled) {
+ const auto& kb = std::get(result.data);
+ float lat = strtof(kb.text.c_str(), nullptr);
+ if (lat >= -90.0f && lat <= 90.0f) {
+ WEATHER_SETTINGS.setLocation(lat, WEATHER_SETTINGS.getLongitude(), WEATHER_SETTINGS.getLocationName());
+ WEATHER_SETTINGS.saveToFile();
+ }
+ }
+ });
+}
+
+void WeatherSettingsActivity::launchLongitudeEntry() {
+ std::string current = std::to_string(WEATHER_SETTINGS.getLongitude());
+ startActivityForResult(
+ std::make_unique(renderer, mappedInput, tr(STR_WEATHER_LONGITUDE), current, 12, false),
+ [this](const ActivityResult& result) {
+ if (!result.isCancelled) {
+ const auto& kb = std::get(result.data);
+ float lon = strtof(kb.text.c_str(), nullptr);
+ if (lon >= -180.0f && lon <= 180.0f) {
+ WEATHER_SETTINGS.setLocation(WEATHER_SETTINGS.getLatitude(), lon, WEATHER_SETTINGS.getLocationName());
+ WEATHER_SETTINGS.saveToFile();
+ }
+ }
+ });
+}
+
+void WeatherSettingsActivity::toggleTempUnit() {
+ auto current = WEATHER_SETTINGS.getTempUnit();
+ WEATHER_SETTINGS.setTempUnit(current == WeatherTempUnit::CELSIUS ? WeatherTempUnit::FAHRENHEIT
+ : WeatherTempUnit::CELSIUS);
+ WEATHER_SETTINGS.saveToFile();
+ requestUpdate();
+}
+
+void WeatherSettingsActivity::toggleWindUnit() {
+ auto current = static_cast(WEATHER_SETTINGS.getWindUnit());
+ WEATHER_SETTINGS.setWindUnit(static_cast((current + 1) % 4));
+ WEATHER_SETTINGS.saveToFile();
+ requestUpdate();
+}
+
+void WeatherSettingsActivity::togglePrecipUnit() {
+ auto current = WEATHER_SETTINGS.getPrecipUnit();
+ WEATHER_SETTINGS.setPrecipUnit(current == WeatherPrecipUnit::MM ? WeatherPrecipUnit::INCH : WeatherPrecipUnit::MM);
+ WEATHER_SETTINGS.saveToFile();
+ requestUpdate();
+}
+
+void WeatherSettingsActivity::render(RenderLock&&) {
+ renderer.clearScreen();
+
+ const auto& metrics = UITheme::getInstance().getMetrics();
+ const auto pageWidth = renderer.getScreenWidth();
+ const auto pageHeight = renderer.getScreenHeight();
+
+ if (showingSearchResults) {
+ // Display search results
+ GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight},
+ tr(STR_WEATHER_SEARCH_RESULTS));
+
+ const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
+ const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
+
+ if (searchResults.empty()) {
+ renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES));
+ } else {
+ GUI.drawList(
+ renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(searchResults.size()),
+ static_cast(selectedIndex),
+ [this](int index) {
+ const auto& r = searchResults[index];
+ std::string label = r.name;
+ if (!r.admin1.empty()) label += ", " + r.admin1;
+ if (!r.country.empty()) label += ", " + r.country;
+ return label;
+ },
+ nullptr, nullptr, nullptr, true);
+ }
+
+ const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
+ GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
+ renderer.displayBuffer();
+ return;
+ }
+
+ // Main settings menu
+ GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_WEATHER_SETTINGS));
+
+ const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
+ const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
+
+ GUI.drawList(
+ renderer, Rect{0, contentTop, pageWidth, contentHeight}, MENU_ITEMS, static_cast(selectedIndex),
+ [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr,
+ [this](int index) -> std::string {
+ switch (index) {
+ case 0: {
+ auto name = WEATHER_SETTINGS.getLocationName();
+ return name.empty() ? std::string(tr(STR_NOT_SET)) : name;
+ }
+ case 1: {
+ char buf[16];
+ snprintf(buf, sizeof(buf), "%.4f", WEATHER_SETTINGS.getLatitude());
+ return std::string(buf);
+ }
+ case 2: {
+ char buf[16];
+ snprintf(buf, sizeof(buf), "%.4f", WEATHER_SETTINGS.getLongitude());
+ return std::string(buf);
+ }
+ case 3:
+ return WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F";
+ case 4:
+ switch (WEATHER_SETTINGS.getWindUnit()) {
+ case WeatherWindUnit::KMH:
+ return "km/h";
+ case WeatherWindUnit::MS:
+ return "m/s";
+ case WeatherWindUnit::MPH:
+ return "mph";
+ case WeatherWindUnit::KNOTS:
+ return "kn";
+ }
+ return "km/h";
+ case 5:
+ return WEATHER_SETTINGS.getPrecipUnit() == WeatherPrecipUnit::MM ? "mm" : "in";
+ }
+ return "";
+ },
+ true);
+
+ const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
+ GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
+
+ renderer.displayBuffer();
+}
diff --git a/src/activities/weather/WeatherSettingsActivity.h b/src/activities/weather/WeatherSettingsActivity.h
new file mode 100644
index 00000000..e0367b73
--- /dev/null
+++ b/src/activities/weather/WeatherSettingsActivity.h
@@ -0,0 +1,44 @@
+#pragma once
+
+#include
+
+#include
+#include
+
+#include "../Activity.h"
+#include "util/ButtonNavigator.h"
+
+/**
+ * Settings submenu for weather configuration.
+ * Supports city search via geocoding, manual lat/lon entry, and unit selection.
+ */
+class WeatherSettingsActivity final : public Activity {
+ public:
+ explicit WeatherSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
+ : Activity("WeatherSettings", renderer, mappedInput) {}
+
+ void onEnter() override;
+ void onExit() override;
+ void loop() override;
+ void render(RenderLock&&) override;
+
+ private:
+ ButtonNavigator buttonNavigator;
+ size_t selectedIndex = 0;
+
+ // City search results (populated when user searches)
+ std::vector searchResults;
+ bool showingSearchResults = false;
+
+ static constexpr int MENU_ITEMS = 6; // Location, Lat, Lon, TempUnit, WindUnit, PrecipUnit
+
+ void handleSelection();
+ void launchCitySearch();
+ void launchLatitudeEntry();
+ void launchLongitudeEntry();
+ void toggleTempUnit();
+ void toggleWindUnit();
+ void togglePrecipUnit();
+
+ bool preventAutoSleep() override { return true; }
+};
diff --git a/src/main.cpp b/src/main.cpp
index 6906af2f..16251230 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -21,6 +21,7 @@
#include "KOReaderCredentialStore.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
+#include "WeatherSettingsStore.h"
#include "activities/Activity.h"
#include "activities/ActivityManager.h"
#include "components/UITheme.h"
@@ -264,6 +265,7 @@ void setup() {
HalClock::applyTimezone(SETTINGS.timeZone);
I18N.loadSettings();
KOREADER_STORE.loadFromFile();
+ WEATHER_SETTINGS.loadFromFile();
UITheme::getInstance().reload();
ButtonNavigator::setMappedInputManager(mappedInputManager);
diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp
index 34a39e0c..fd0b081f 100644
--- a/src/network/HttpDownloader.cpp
+++ b/src/network/HttpDownloader.cpp
@@ -55,37 +55,52 @@ class FileWriteStream final : public Stream {
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
std::unique_ptr client;
+ LOG_DBG("HTTP", "fetchUrl[1] start (https=%d)", UrlUtils::isHttpsUrl(url) ? 1 : 0);
if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new NetworkClientSecure();
+ LOG_DBG("HTTP", "fetchUrl[2] created NetworkClientSecure");
secureClient->setInsecure();
+ LOG_DBG("HTTP", "fetchUrl[3] setInsecure done");
client.reset(secureClient);
} else {
client.reset(new NetworkClient());
+ LOG_DBG("HTTP", "fetchUrl[2] created NetworkClient");
}
HTTPClient http;
LOG_DBG("HTTP", "Fetching: %s", url.c_str());
+ LOG_DBG("HTTP", "fetchUrl[4] begin() before");
http.begin(*client, url.c_str());
+ LOG_DBG("HTTP", "fetchUrl[5] begin() after");
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
+ LOG_DBG("HTTP", "fetchUrl[6] redirects configured");
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
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
String encoded = base64::encode(credentials.c_str());
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();
+ LOG_DBG("HTTP", "fetchUrl[10] GET() after code=%d", httpCode);
if (httpCode != HTTP_CODE_OK) {
LOG_ERR("HTTP", "Fetch failed: %d", httpCode);
+ LOG_DBG("HTTP", "fetchUrl[11] end() after GET failure");
http.end();
return false;
}
+ LOG_DBG("HTTP", "fetchUrl[12] writeToStream() before");
http.writeToStream(&outContent);
+ LOG_DBG("HTTP", "fetchUrl[13] writeToStream() after");
+ LOG_DBG("HTTP", "fetchUrl[14] end() before success return");
http.end();
LOG_DBG("HTTP", "Fetch success");
@@ -105,30 +120,41 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
ProgressCallback progress) {
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
std::unique_ptr client;
+ LOG_DBG("HTTP", "downloadToFile[1] start (https=%d)", UrlUtils::isHttpsUrl(url) ? 1 : 0);
if (UrlUtils::isHttpsUrl(url)) {
auto* secureClient = new NetworkClientSecure();
+ LOG_DBG("HTTP", "downloadToFile[2] created NetworkClientSecure");
secureClient->setInsecure();
+ LOG_DBG("HTTP", "downloadToFile[3] setInsecure done");
client.reset(secureClient);
} else {
client.reset(new NetworkClient());
+ LOG_DBG("HTTP", "downloadToFile[2] created NetworkClient");
}
HTTPClient http;
LOG_DBG("HTTP", "Downloading: %s", url.c_str());
LOG_DBG("HTTP", "Destination: %s", destPath.c_str());
+ LOG_DBG("HTTP", "downloadToFile[4] begin() before");
http.begin(*client, url.c_str());
+ LOG_DBG("HTTP", "downloadToFile[5] begin() after");
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
+ LOG_DBG("HTTP", "downloadToFile[6] redirects configured");
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
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
String encoded = base64::encode(credentials.c_str());
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();
+ LOG_DBG("HTTP", "downloadToFile[10] GET() after code=%d", httpCode);
if (httpCode != HTTP_CODE_OK) {
LOG_ERR("HTTP", "Download failed: %d", httpCode);
http.end();
@@ -158,10 +184,14 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
// Let HTTPClient handle chunked decoding and stream body bytes into the file.
FileWriteStream fileStream(file, contentLength, progress);
+ LOG_DBG("HTTP", "downloadToFile[11] writeToStream() before");
const int writeResult = http.writeToStream(&fileStream);
+ LOG_DBG("HTTP", "downloadToFile[12] writeToStream() after result=%d", writeResult);
file.close();
+ LOG_DBG("HTTP", "downloadToFile[13] file closed");
http.end();
+ LOG_DBG("HTTP", "downloadToFile[14] http end done");
if (writeResult < 0) {
LOG_ERR("HTTP", "writeToStream error: %d", writeResult);