#include "WeatherActivity.h" #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; } } enum class MoonPhaseType { NEW, WAXING_CRESCENT, FIRST_QUARTER, WAXING_GIBBOUS, FULL, WANING_GIBBOUS, LAST_QUARTER, WANING_CRESCENT, }; static MoonPhaseType getMoonPhaseType(float phase) { if (phase < 0.03f || phase > 0.97f) return MoonPhaseType::NEW; if (phase < 0.22f) return MoonPhaseType::WAXING_CRESCENT; if (phase < 0.28f) return MoonPhaseType::FIRST_QUARTER; if (phase < 0.47f) return MoonPhaseType::WAXING_GIBBOUS; if (phase < 0.53f) return MoonPhaseType::FULL; if (phase < 0.72f) return MoonPhaseType::WANING_GIBBOUS; if (phase < 0.78f) return MoonPhaseType::LAST_QUARTER; return MoonPhaseType::WANING_CRESCENT; } static float getMoonPhaseCycleFromTime(time_t timestamp) { constexpr double knownNewMoon = 947182440.0; // Jan 6, 2000 18:14 UTC constexpr double lunarCycle = 2551442.8; // Seconds in synodic month double secondsSince = static_cast(timestamp) - knownNewMoon; double phase = secondsSince / lunarCycle; phase = phase - floor(phase); if (phase < 0.0) phase += 1.0; return static_cast(phase); } static StrId getMoonPhaseStrId(MoonPhaseType type) { switch (type) { case MoonPhaseType::NEW: return StrId::STR_WEATHER_MOON_NEW; case MoonPhaseType::WAXING_CRESCENT: return StrId::STR_WEATHER_MOON_WAXING_CRESCENT; case MoonPhaseType::FIRST_QUARTER: return StrId::STR_WEATHER_MOON_FIRST_QUARTER; case MoonPhaseType::WAXING_GIBBOUS: return StrId::STR_WEATHER_MOON_WAXING_GIBBOUS; case MoonPhaseType::FULL: return StrId::STR_WEATHER_MOON_FULL; case MoonPhaseType::WANING_GIBBOUS: return StrId::STR_WEATHER_MOON_WANING_GIBBOUS; case MoonPhaseType::LAST_QUARTER: return StrId::STR_WEATHER_MOON_LAST_QUARTER; case MoonPhaseType::WANING_CRESCENT: return StrId::STR_WEATHER_MOON_WANING_CRESCENT; default: return StrId::STR_WEATHER_DESC_UNKNOWN; } } static float getMoonIlluminationFromCycle(float cycle) { // 0.0 = new, 0.5 = full; convert to illuminated fraction const float pi = 3.14159265358979323846f; float illum = 0.5f * (1.0f - cosf(2.0f * pi * cycle)); return illum; } static void formatSunriseSunsetTime(char* buf, size_t bufSize, time_t epoch, int utcOffsetSeconds, bool use24h) { time_t localTime = epoch + utcOffsetSeconds; struct tm timeinfo; gmtime_r(&localTime, &timeinfo); if (use24h) { snprintf(buf, bufSize, "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); } else { int hour = timeinfo.tm_hour % 12; if (hour == 0) hour = 12; const char* suffix = timeinfo.tm_hour < 12 ? "AM" : "PM"; snprintf(buf, bufSize, "%d:%02d%s", hour, timeinfo.tm_min, suffix); } } static time_t getNextFullMoonTime(time_t now) { constexpr double knownNewMoon = 947182440.0; constexpr double lunarCycle = 2551442.8; double secondsSince = static_cast(now) - knownNewMoon; double synodic = secondsSince / lunarCycle; synodic -= floor(synodic); if (synodic < 0.0) synodic += 1.0; double untilFull = 0.5 - synodic; if (untilFull < 0.0) untilFull += 1.0; return now + static_cast(untilFull * lunarCycle + 0.5); } } // namespace void WeatherActivity::onEnter() { Activity::onEnter(); // Force landscape orientation for weather display { RenderLock lock(*this); renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); } state = State::LOADING_CACHE; errorMessage.clear(); forceRefresh = false; showRefreshPopup = false; requestUpdate(); loadAndDisplay(); } void WeatherActivity::onExit() { Activity::onExit(); // Weather screen is always landscape; restore app UI to portrait on exit. { RenderLock lock(*this); renderer.setOrientation(GfxRenderer::Orientation::Portrait); } 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) { RenderLock lock(*this); 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::openSettingsActivity() { { RenderLock lock(*this); renderer.setOrientation(GfxRenderer::Orientation::Portrait); } startActivityForResult(std::make_unique(renderer, mappedInput), [this](const ActivityResult&) { { RenderLock lock(*this); renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise); } forceRefresh = true; loadAndDisplay(); }); } void WeatherActivity::triggerRefresh(const bool showPopup) { showRefreshPopup = showPopup; if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = State::FETCHING; requestUpdate(true); fetchWeather(); } else { launchWifiSelection(); } } void WeatherActivity::loop() { if (state == State::WIFI_SELECTION) { return; // Handled by WifiSelectionActivity } if (state == State::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { openSettingsActivity(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Left) || mappedInput.wasReleased(MappedInputManager::Button::Right)) { triggerRefresh(false); } 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)) { openSettingsActivity(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Left) || mappedInput.wasReleased(MappedInputManager::Button::Right)) { triggerRefresh(true); } } 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_SHORT), 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)); } else { 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_SIZE x WEATHER_ICON_SIZE) auto iconType = getWeatherIconType(cur.weatherCode, cur.isDay); const uint8_t* icon = getWeatherIconLarge(iconType); int iconX = x + (w - WEATHER_ICON_SIZE) / 2; drawWeatherIconWithOrientation(renderer, icon, iconX, textY + 2, WEATHER_ICON_SIZE); textY += WEATHER_ICON_SIZE + 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 StrId monthNameIds[] = { StrId::STR_WEATHER_MONTH_JAN, StrId::STR_WEATHER_MONTH_FEB, StrId::STR_WEATHER_MONTH_MAR, StrId::STR_WEATHER_MONTH_APR, StrId::STR_WEATHER_MONTH_MAY, StrId::STR_WEATHER_MONTH_JUN, StrId::STR_WEATHER_MONTH_JUL, StrId::STR_WEATHER_MONTH_AUG, StrId::STR_WEATHER_MONTH_SEP, StrId::STR_WEATHER_MONTH_OCT, StrId::STR_WEATHER_MONTH_NOV, StrId::STR_WEATHER_MONTH_DEC}; const char* unitSuffix = WEATHER_SETTINGS.getTempUnit() == WeatherTempUnit::CELSIUS ? "C" : "F"; 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* monthName = I18N.get(monthNameIds[timeinfo.tm_mon]); snprintf(dateBuf, sizeof(dateBuf), "%s %d", monthName, 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_SIZE for all days) auto iconType = getWeatherIconType(day.weatherCode, true); const uint8_t* icon = getWeatherIconLarge(iconType); int iconX = cardX + (cardWidth - WEATHER_ICON_SIZE) / 2; drawWeatherIconWithOrientation(renderer, icon, iconX, textY, WEATHER_ICON_SIZE); textY += WEATHER_ICON_SIZE + 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); textY += 16; // Sunrise/Sunset { char sunriseStr[16]; char sunsetStr[16]; formatSunriseSunsetTime(sunriseStr, sizeof(sunriseStr), day.sunrise, weatherData.utcOffsetSeconds, !SETTINGS.clockFormat12h); formatSunriseSunsetTime(sunsetStr, sizeof(sunsetStr), day.sunset, weatherData.utcOffsetSeconds, !SETTINGS.clockFormat12h); char sunBuf[48]; snprintf(sunBuf, sizeof(sunBuf), "%s: %s/%s", tr(STR_WEATHER_SUN_INFO), sunriseStr, sunsetStr); auto truncSun = renderer.truncatedText(SMALL_FONT_ID, sunBuf, cardWidth - 8); int sunWidth = renderer.getTextWidth(SMALL_FONT_ID, truncSun.c_str()); renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - sunWidth) / 2, textY, truncSun.c_str()); textY += 14; } // Moon phase (derived from local calculation when API data unavailable) { float phase = day.moonPhase; if (phase < 0.0f || phase > 1.0f) { phase = getMoonPhaseCycleFromTime(day.date + weatherData.utcOffsetSeconds + 12 * 3600); } MoonPhaseType phaseType = getMoonPhaseType(phase); float illumination = getMoonIlluminationFromCycle(phase); int phasePercent = static_cast(illumination * 100.0f + 0.5f); const char* phaseName = I18N.get(getMoonPhaseStrId(phaseType)); char moonBuf[48]; snprintf(moonBuf, sizeof(moonBuf), "%s %3d%%", phaseName, phasePercent); int moonWidth = renderer.getTextWidth(SMALL_FONT_ID, moonBuf); renderer.drawText(SMALL_FONT_ID, cardX + (cardWidth - moonWidth) / 2, textY, moonBuf); textY += 14; } // 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; } 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 }