Initial scaffold
This commit is contained in:
@@ -290,3 +290,22 @@ 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_LOCATION: "Location: "
|
||||
STR_WEATHER_NO_LOCATION: "No location set for weather"
|
||||
STR_WEATHER_FETCH_FAILED: "Failed to fetch weather"
|
||||
STR_WEATHER_SETTINGS: "Settings"
|
||||
STR_WEATHER_REFRESH: "Refresh Weather"
|
||||
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: "Precipitation: "
|
||||
STR_WEATHER_PRECIP_UNIT: "Units:"
|
||||
STR_WEATHER_WIND_UNIT: "Units:"
|
||||
STR_WEATHER_TEMP_UNIT: "Units:"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
#include "WeatherClient.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <ctime>
|
||||
|
||||
#include "../../src/network/HttpDownloader.h"
|
||||
|
||||
namespace {
|
||||
constexpr char WEATHER_CACHE_FILE[] = "/.crosspoint/weather_cache.json";
|
||||
|
||||
std::string buildForecastUrl(const WeatherSettingsStore& settings) {
|
||||
std::string url = "https://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));
|
||||
}
|
||||
}
|
||||
|
||||
return fetchFromApi(settings);
|
||||
}
|
||||
|
||||
WeatherData WeatherClient::fetchFromApi(const WeatherSettingsStore& settings) {
|
||||
WeatherData data;
|
||||
std::string url = buildForecastUrl(settings);
|
||||
LOG_DBG("WEA", "Fetching weather from API");
|
||||
|
||||
std::string response;
|
||||
if (!HttpDownloader::fetchUrl(url, response)) {
|
||||
data.errorMessage = "Network error";
|
||||
LOG_ERR("WEA", "Failed to fetch weather data");
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!parseWeatherJson(response, data)) {
|
||||
LOG_ERR("WEA", "Failed to parse weather JSON");
|
||||
return data;
|
||||
}
|
||||
|
||||
data.valid = true;
|
||||
time(&data.fetchedAt);
|
||||
saveCache(data);
|
||||
LOG_DBG("WEA", "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<JsonObject>();
|
||||
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<JsonArray>();
|
||||
for (const auto& day : data.daily) {
|
||||
JsonObject d = dailyArr.add<JsonObject>();
|
||||
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<JsonArray>();
|
||||
for (const auto& hour : data.hourly) {
|
||||
JsonObject h = hourlyArr.add<JsonObject>();
|
||||
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<JsonArray>();
|
||||
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<JsonArray>();
|
||||
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<GeocodingResult> WeatherClient::searchCity(const std::string& query) {
|
||||
std::vector<GeocodingResult> 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<JsonArray>();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#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<GeocodingResult> 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
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<DailyForecast> daily;
|
||||
std::vector<HourlyForecast> 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;
|
||||
};
|
||||
@@ -0,0 +1,396 @@
|
||||
#include "WeatherIcons.h"
|
||||
|
||||
// ============================================================================
|
||||
// 48x48 Large Weather Icons (1-bit, MSB-first, row-major)
|
||||
// Each row = 48 pixels = 6 bytes. Total = 48 * 6 = 288 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.
|
||||
// ============================================================================
|
||||
|
||||
// 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
|
||||
|
||||
// ============================================================================
|
||||
// Lookup functions
|
||||
// ============================================================================
|
||||
|
||||
const uint8_t* getWeatherIconLarge(WeatherIconType type) {
|
||||
switch (type) {
|
||||
case WeatherIconType::CLEAR_DAY:
|
||||
return ICON_CLEAR_DAY_48;
|
||||
case WeatherIconType::CLEAR_NIGHT:
|
||||
return ICON_CLEAR_NIGHT_48;
|
||||
case WeatherIconType::PARTLY_CLOUDY_DAY:
|
||||
return ICON_PARTLY_CLOUDY_DAY_48;
|
||||
case WeatherIconType::PARTLY_CLOUDY_NIGHT:
|
||||
return ICON_PARTLY_CLOUDY_NIGHT_48;
|
||||
case WeatherIconType::OVERCAST:
|
||||
return ICON_OVERCAST_48;
|
||||
case WeatherIconType::FOG:
|
||||
return ICON_FOG_48;
|
||||
case WeatherIconType::DRIZZLE:
|
||||
case WeatherIconType::RAIN:
|
||||
case WeatherIconType::SHOWERS:
|
||||
return ICON_RAIN_48;
|
||||
case WeatherIconType::SNOW:
|
||||
return ICON_SNOW_48;
|
||||
case WeatherIconType::THUNDERSTORM:
|
||||
return ICON_THUNDERSTORM_48;
|
||||
default:
|
||||
return ICON_OVERCAST_48;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
// Weather icon size constants
|
||||
constexpr int WEATHER_ICON_LARGE = 48; // 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 (48x48, 1-bit, MSB first)
|
||||
const uint8_t* getWeatherIconLarge(WeatherIconType type);
|
||||
|
||||
// Get the appropriate small icon bitmap (24x24, 1-bit, MSB first)
|
||||
const uint8_t* getWeatherIconSmall(WeatherIconType type);
|
||||
|
||||
// Wind direction arrow text (N, NE, E, SE, S, SW, W, NW)
|
||||
const char* getWindDirectionText(int degrees);
|
||||
@@ -0,0 +1,96 @@
|
||||
#include "WeatherSettingsStore.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
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<uint8_t>(tempUnit);
|
||||
doc["windUnit"] = static_cast<uint8_t>(windUnit);
|
||||
doc["precipUnit"] = static_cast<uint8_t>(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<WeatherTempUnit>(doc["tempUnit"] | (uint8_t)0);
|
||||
windUnit = static_cast<WeatherWindUnit>(doc["windUnit"] | (uint8_t)0);
|
||||
precipUnit = static_cast<WeatherPrecipUnit>(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";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,467 @@
|
||||
#include "WeatherActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WeatherClient.h>
|
||||
#include <WeatherIcons.h>
|
||||
#include <WeatherSettingsStore.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "WeatherSettingsActivity.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void WeatherActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Force landscape orientation for weather display
|
||||
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
|
||||
|
||||
state = State::LOADING_CACHE;
|
||||
errorMessage.clear();
|
||||
forceRefresh = false;
|
||||
requestUpdate();
|
||||
|
||||
loadAndDisplay();
|
||||
}
|
||||
|
||||
void WeatherActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Restore orientation from settings
|
||||
renderer.setOrientation(static_cast<GfxRenderer::Orientation>(SETTINGS.orientation));
|
||||
|
||||
WiFi.mode(WIFI_OFF);
|
||||
}
|
||||
|
||||
void WeatherActivity::loadAndDisplay() {
|
||||
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) {
|
||||
weatherData = std::move(cached);
|
||||
state = State::WEATHER_DISPLAY;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Need fresh data - check WiFi
|
||||
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<WifiSelectionActivity>(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);
|
||||
|
||||
if (connected) {
|
||||
state = State::FETCHING;
|
||||
requestUpdate(true);
|
||||
fetchWeather();
|
||||
} else {
|
||||
WiFi.disconnect();
|
||||
WiFi.mode(WIFI_OFF);
|
||||
state = State::ERROR;
|
||||
errorMessage = tr(STR_WIFI_CONN_FAILED);
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void WeatherActivity::fetchWeather() {
|
||||
weatherData = WeatherClient::getWeather(WEATHER_SETTINGS, true);
|
||||
if (weatherData.valid) {
|
||||
state = State::WEATHER_DISPLAY;
|
||||
} else {
|
||||
state = State::ERROR;
|
||||
errorMessage = weatherData.errorMessage.empty() ? tr(STR_WEATHER_FETCH_FAILED) : weatherData.errorMessage.c_str();
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void WeatherActivity::loop() {
|
||||
if (state == State::WIFI_SELECTION) {
|
||||
return; // Handled by WifiSelectionActivity
|
||||
}
|
||||
|
||||
if (state == State::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Retry
|
||||
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 (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
|
||||
startActivityForResult(std::make_unique<WeatherSettingsActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult&) {
|
||||
// Re-apply landscape after returning from settings
|
||||
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
|
||||
// Refresh after settings change
|
||||
forceRefresh = true;
|
||||
loadAndDisplay();
|
||||
});
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||
// Manual refresh
|
||||
forceRefresh = true;
|
||||
loadAndDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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_RETRY), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// DISPLAY state - Landscape layout (800x480)
|
||||
//
|
||||
// +------------------+------------------------------------+
|
||||
// | Current weather | Daily forecast cards |
|
||||
// | (icon, temp, | [Day1] [Day2] [Day3] ... |
|
||||
// | details) | |
|
||||
// | ~200px wide | ~600px wide |
|
||||
// +------------------+------------------------------------+
|
||||
// | |
|
||||
// | 48-hour temperature + precipitation graph |
|
||||
// | ~full width, ~200px high |
|
||||
// +--------------------------------------------------------+
|
||||
// | [Back] [Settings] [Refresh] Button hints |
|
||||
// +--------------------------------------------------------+
|
||||
|
||||
constexpr int leftPanelWidth = 220;
|
||||
constexpr int buttonHintsHeight = 30;
|
||||
constexpr int graphHeight = 190;
|
||||
const int topSectionHeight = pageHeight - graphHeight - buttonHintsHeight;
|
||||
|
||||
// Draw current conditions (left panel)
|
||||
renderCurrentConditions(0, 0, leftPanelWidth, topSectionHeight);
|
||||
|
||||
// Draw daily forecast (right of current conditions)
|
||||
renderDailyForecast(leftPanelWidth, 0, pageWidth - leftPanelWidth, topSectionHeight);
|
||||
|
||||
// Separator line
|
||||
renderer.drawLine(0, topSectionHeight, pageWidth, topSectionHeight);
|
||||
|
||||
// Draw hourly graph
|
||||
renderHourlyGraph(0, topSectionHeight + 1, pageWidth, graphHeight - 1);
|
||||
|
||||
// Separator line above button hints
|
||||
renderer.drawLine(0, pageHeight - buttonHintsHeight, pageWidth, pageHeight - buttonHintsHeight);
|
||||
|
||||
// Button hints
|
||||
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();
|
||||
}
|
||||
|
||||
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 (48x48)
|
||||
auto iconType = getWeatherIconType(cur.weatherCode, cur.isDay);
|
||||
const uint8_t* icon = getWeatherIconLarge(iconType);
|
||||
int iconX = x + (w - WEATHER_ICON_LARGE) / 2;
|
||||
renderer.drawImage(icon, iconX, textY, WEATHER_ICON_LARGE, 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);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 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 = getWeatherDescription(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];
|
||||
|
||||
// Wind
|
||||
snprintf(detailBuf, sizeof(detailBuf), "%s: %.0f %s %s", tr(STR_WEATHER_WIND), cur.windSpeed,
|
||||
WEATHER_SETTINGS.getWindUnitParam(), windDir);
|
||||
renderer.drawText(SMALL_FONT_ID, x + 5, textY, detailBuf);
|
||||
textY += 14;
|
||||
|
||||
// Humidity
|
||||
snprintf(detailBuf, sizeof(detailBuf), "%s: %d%%", tr(STR_WEATHER_HUMIDITY), cur.humidity);
|
||||
renderer.drawText(SMALL_FONT_ID, x + 5, textY, detailBuf);
|
||||
textY += 14;
|
||||
|
||||
// Precipitation
|
||||
snprintf(detailBuf, sizeof(detailBuf), "%s: %.1f %s", tr(STR_WEATHER_PRECIP), cur.precipitation,
|
||||
WEATHER_SETTINGS.getPrecipUnitParam());
|
||||
renderer.drawText(SMALL_FONT_ID, x + 5, textY, detailBuf);
|
||||
textY += 14;
|
||||
|
||||
// UV Index
|
||||
snprintf(detailBuf, sizeof(detailBuf), "UV: %.1f", cur.uvIndex);
|
||||
renderer.drawText(SMALL_FONT_ID, x + 5, textY, 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;
|
||||
|
||||
int numDays = static_cast<int>(weatherData.daily.size());
|
||||
int cardWidth = w / numDays;
|
||||
|
||||
for (int i = 0; i < numDays; i++) {
|
||||
const auto& day = weatherData.daily[i];
|
||||
int cardX = x + i * cardWidth;
|
||||
int textY = y + 10;
|
||||
|
||||
// Day name
|
||||
struct tm timeinfo;
|
||||
time_t localDate = day.date + weatherData.utcOffsetSeconds;
|
||||
gmtime_r(&localDate, &timeinfo);
|
||||
char dayName[16];
|
||||
// Manual short day names to avoid locale issues on ESP32
|
||||
const char* dayNames[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
|
||||
snprintf(dayName, sizeof(dayName), "%s", dayNames[timeinfo.tm_wday]);
|
||||
|
||||
int dayNameWidth = renderer.getTextWidth(SMALL_FONT_ID, dayName, EpdFontFamily::BOLD);
|
||||
renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - dayNameWidth) / 2, textY, dayName, true, EpdFontFamily::BOLD);
|
||||
textY += 18;
|
||||
|
||||
// Weather icon (24x24)
|
||||
// Use isDay=true for daily forecasts
|
||||
auto iconType = getWeatherIconType(day.weatherCode, true);
|
||||
const uint8_t* icon = getWeatherIconSmall(iconType);
|
||||
int iconX = cardX + (cardWidth - WEATHER_ICON_SMALL) / 2;
|
||||
renderer.drawImage(icon, iconX, textY, WEATHER_ICON_SMALL, WEATHER_ICON_SMALL);
|
||||
textY += WEATHER_ICON_SMALL + 5;
|
||||
|
||||
// High / Low temp
|
||||
char tempBuf[24];
|
||||
snprintf(tempBuf, sizeof(tempBuf), "%.0f/%.0f", day.tempMax, day.tempMin);
|
||||
int tempWidth = renderer.getTextWidth(SMALL_FONT_ID, tempBuf);
|
||||
renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - tempWidth) / 2, textY, tempBuf);
|
||||
textY += 15;
|
||||
|
||||
// Precipitation
|
||||
if (day.precipSum > 0) {
|
||||
char precipBuf[16];
|
||||
snprintf(precipBuf, sizeof(precipBuf), "%.1f%s", day.precipSum, WEATHER_SETTINGS.getPrecipUnitParam());
|
||||
int precipWidth = renderer.getTextWidth(SMALL_FONT_ID, precipBuf);
|
||||
renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - precipWidth) / 2, textY, precipBuf);
|
||||
}
|
||||
|
||||
// Card separator
|
||||
if (i < numDays - 1) {
|
||||
renderer.drawLine(cardX + cardWidth, y, cardX + cardWidth, y + h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void WeatherActivity::renderHourlyGraph(int x, int y, int w, int h) {
|
||||
if (weatherData.hourly.empty()) return;
|
||||
|
||||
constexpr int leftMargin = 40; // Space for Y-axis labels
|
||||
constexpr int rightMargin = 10;
|
||||
constexpr int topMargin = 15;
|
||||
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 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
|
||||
renderer.drawText(SMALL_FONT_ID, x + 5, y + 3, tr(STR_WEATHER_48H_FORECAST), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Draw Y-axis labels (temperature)
|
||||
float tempStep = tempRange / 4.0f;
|
||||
for (int i = 0; i <= 4; i++) {
|
||||
float temp = tempMax - i * tempStep;
|
||||
int labelY = graphY + i * graphH / 4;
|
||||
char label[8];
|
||||
snprintf(label, sizeof(label), "%.0f", temp);
|
||||
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<int>(i * graphW / (numPoints - 1));
|
||||
float normalized = (weatherData.hourly[i].temperature - tempMin) / tempRange;
|
||||
int py = graphY + graphH - static_cast<int>(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<int>(i * graphW / (numPoints - 1));
|
||||
int barH = static_cast<int>((precip / maxPrecip) * precipBarMaxHeight);
|
||||
if (barH < 1) barH = 1;
|
||||
int barW = std::max(1, graphW / static_cast<int>(numPoints) - 1);
|
||||
renderer.fillRect(px - barW / 2, precipY + precipBarMaxHeight - barH, barW, barH);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw X-axis labels (every 6 hours)
|
||||
for (size_t i = 0; i < numPoints; i += 6) {
|
||||
int px = graphX + static_cast<int>(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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <WeatherData.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
#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;
|
||||
|
||||
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; }
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
#include "WeatherSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WeatherClient.h>
|
||||
#include <WeatherSettingsStore.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_WEATHER_SEARCH_CITY), "", 64, false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (result.isCancelled) return;
|
||||
|
||||
const auto& kb = std::get<KeyboardResult>(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<WifiSelectionActivity>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_WEATHER_LATITUDE), current, 12, false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_WEATHER_LONGITUDE), current, 12, false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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<uint8_t>(WEATHER_SETTINGS.getWindUnit());
|
||||
WEATHER_SETTINGS.setWindUnit(static_cast<WeatherWindUnit>((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<int>(searchResults.size()),
|
||||
static_cast<int>(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<int>(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();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <WeatherData.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<GeocodingResult> 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; }
|
||||
};
|
||||
Reference in New Issue
Block a user