From f143c16c25b5d2579e546009b1e70f1fe6b67827 Mon Sep 17 00:00:00 2001 From: jarnokie Date: Wed, 24 Jun 2026 21:23:03 +0300 Subject: [PATCH] added weather forecast fetch from open-meteo --- include/screens.h | 4 +- include/weather_forecast.h | 37 +++++ include/wifi_credentials.example.h | 4 + src/carousel.cpp | 2 +- src/main.cpp | 12 +- src/screens.cpp | 100 +++++++++--- src/weather_forecast.cpp | 247 +++++++++++++++++++++++++++++ 7 files changed, 381 insertions(+), 25 deletions(-) create mode 100644 include/weather_forecast.h create mode 100644 src/weather_forecast.cpp diff --git a/include/screens.h b/include/screens.h index e46c0bb..de4c607 100644 --- a/include/screens.h +++ b/include/screens.h @@ -5,6 +5,7 @@ #include "am2302_sensor.h" #include "internet_time.h" +#include "weather_forecast.h" #include "wifi_connection.h" void setInternetTimeInfo(InternetTimeInfo timeInfo); @@ -13,6 +14,7 @@ void drawHelloWorldScreen(U8G2 &u8g2); void drawInternetConnectionScreen(U8G2 &u8g2, WifiConnectionInfo wifiInfo); void setEnvironmentReading(Am2302Reading reading); void drawEnvironmentScreen(U8G2 &u8g2); -void drawWeatherIconScreen(U8G2 &u8g2); +void setWeatherForecastInfo(WeatherForecastInfo forecastInfo); +void drawWeatherForecastScreen(U8G2 &u8g2); #endif diff --git a/include/weather_forecast.h b/include/weather_forecast.h new file mode 100644 index 0000000..e5a2733 --- /dev/null +++ b/include/weather_forecast.h @@ -0,0 +1,37 @@ +#ifndef WEATHER_FORECAST_H +#define WEATHER_FORECAST_H + +#include + +struct WeatherForecastInfo +{ + bool valid; + float currentTemperatureC; + float minTemperatureC; + float maxTemperatureC; + float precipitationMm; + uint8_t precipitationProbabilityPercent; + int weatherCode; +}; + +class WeatherForecast +{ +public: + WeatherForecast(); + void update(unsigned long now, bool wifiConnected); + WeatherForecastInfo info() const; + bool consumeDirty(); + +private: + void fetch(unsigned long now); + bool readResponse(String &body); + bool parseForecast(const String &body, WeatherForecastInfo &forecast) const; + bool parseFloatArray(const String &body, const char *name, float *values, size_t maxCount, size_t &count) const; + bool parseIntArray(const String &body, const char *name, int *values, size_t maxCount, size_t &count) const; + + WeatherForecastInfo current_; + unsigned long nextAttempt_; + bool dirty_; +}; + +#endif diff --git a/include/wifi_credentials.example.h b/include/wifi_credentials.example.h index ad8f93c..85bb1a0 100644 --- a/include/wifi_credentials.example.h +++ b/include/wifi_credentials.example.h @@ -7,4 +7,8 @@ const char WIFI_PASSWORD[] = "your-wifi-password"; // Offset from UTC in seconds. Examples: Finland winter 7200, Finland summer 10800. #define TIME_UTC_OFFSET_SECONDS 0 +// Coordinates used for the weather forecast. +#define WEATHER_LATITUDE 60.1699 +#define WEATHER_LONGITUDE 24.9384 + #endif diff --git a/src/carousel.cpp b/src/carousel.cpp index b6a1d7e..9ec2f97 100644 --- a/src/carousel.cpp +++ b/src/carousel.cpp @@ -1,6 +1,6 @@ #include "carousel.h" -const unsigned long pageInterval = 5000; +const unsigned long pageInterval = 30000; Carousel::Carousel(Screen *screens, size_t screenCount) : screens_(screens), diff --git a/src/main.cpp b/src/main.cpp index 4c23e85..9b55a41 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,7 @@ #include "internet_time.h" #include "menu.h" #include "screens.h" +#include "weather_forecast.h" #include "wifi_connection.h" U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R2, U8X8_PIN_NONE); @@ -18,6 +19,7 @@ uint8_t buttonSelect = A5; Am2302Sensor am2302(A3); WifiConnection wifiConnection; InternetTime internetTime; +WeatherForecast weatherForecast; enum class AppMode { @@ -30,7 +32,7 @@ enum class AppMode Screen screens[] = { { drawTimeScreen }, { drawEnvironmentScreen }, - { drawWeatherIconScreen } + { drawWeatherForecastScreen } }; Carousel carousel(screens, sizeof(screens) / sizeof(screens[0])); Menu menu; @@ -145,6 +147,7 @@ void loop(){ const unsigned long now = millis(); wifiConnection.update(now); internetTime.update(now, wifiConnection.info().connected); + weatherForecast.update(now, wifiConnection.info().connected); if (internetTime.consumeDirty()) { setInternetTimeInfo(internetTime.info()); @@ -153,6 +156,13 @@ void loop(){ } } + if (weatherForecast.consumeDirty()) { + setWeatherForecastInfo(weatherForecast.info()); + if (appMode == AppMode::Carousel) { + carousel.requestRedraw(); + } + } + if (appMode == AppMode::Carousel) { carousel.update(now); } diff --git a/src/screens.cpp b/src/screens.cpp index 98b282f..f0c266d 100644 --- a/src/screens.cpp +++ b/src/screens.cpp @@ -4,6 +4,7 @@ namespace { Am2302Reading environmentReading = {0.0F, 0.0F, false}; InternetTimeInfo internetTimeInfo = {false, 0, 0, 0, 0, 0}; +WeatherForecastInfo weatherForecastInfo = {false, 0.0F, 0.0F, 0.0F, 0.0F, 0, 0}; void printTwoDigits(U8G2 &u8g2, uint8_t value) { @@ -13,26 +14,50 @@ void printTwoDigits(U8G2 &u8g2, uint8_t value) u8g2.print(value); } -void drawSun(U8G2 &u8g2, uint8_t x, uint8_t y) +const char *weatherDescription(int code) { - u8g2.drawDisc(x, y, 11); - u8g2.drawLine(x, y - 18, x, y - 14); - u8g2.drawLine(x, y + 14, x, y + 18); - u8g2.drawLine(x - 18, y, x - 14, y); - u8g2.drawLine(x + 14, y, x + 18, y); - u8g2.drawLine(x - 13, y - 13, x - 10, y - 10); - u8g2.drawLine(x + 10, y - 10, x + 13, y - 13); - u8g2.drawLine(x - 13, y + 13, x - 10, y + 10); - u8g2.drawLine(x + 10, y + 10, x + 13, y + 13); -} - -void drawCloud(U8G2 &u8g2, uint8_t x, uint8_t y) -{ - u8g2.drawDisc(x + 18, y + 14, 12); - u8g2.drawDisc(x + 34, y + 8, 17); - u8g2.drawDisc(x + 52, y + 15, 13); - u8g2.drawBox(x + 17, y + 15, 38, 14); - u8g2.drawRBox(x + 8, y + 18, 58, 15, 7); + switch (code) { + case 0: + return "Clear"; + case 1: + case 2: + return "Partly cloudy"; + case 3: + return "Cloudy"; + case 45: + case 48: + return "Fog"; + case 51: + case 53: + case 55: + case 56: + case 57: + return "Drizzle"; + case 61: + case 63: + case 65: + case 66: + case 67: + return "Rain"; + case 71: + case 73: + case 75: + case 77: + return "Snow"; + case 80: + case 81: + case 82: + return "Showers"; + case 85: + case 86: + return "Snow showers"; + case 95: + case 96: + case 99: + return "Thunder"; + default: + return "Forecast"; + } } } @@ -119,8 +144,39 @@ void drawEnvironmentScreen(U8G2 &u8g2) u8g2.print(" %"); } -void drawWeatherIconScreen(U8G2 &u8g2) +void setWeatherForecastInfo(WeatherForecastInfo forecastInfo) { - drawSun(u8g2, 78, 22); - drawCloud(u8g2, 29, 24); + weatherForecastInfo = forecastInfo; +} + +void drawWeatherForecastScreen(U8G2 &u8g2) +{ + u8g2.setFont(u8g2_font_6x12_tr); + u8g2.drawStr(0, 12, "Weather next 12h"); + + if (!weatherForecastInfo.valid) { + u8g2.drawStr(0, 34, "Weather has not"); + u8g2.drawStr(0, 50, "been updated"); + return; + } + + u8g2.setCursor(0, 28); + u8g2.print("Now "); + u8g2.print(weatherForecastInfo.currentTemperatureC, 1); + u8g2.print(" C "); + u8g2.print(weatherDescription(weatherForecastInfo.weatherCode)); + + u8g2.setCursor(0, 42); + u8g2.print("Temp "); + u8g2.print(weatherForecastInfo.minTemperatureC, 0); + u8g2.print("-"); + u8g2.print(weatherForecastInfo.maxTemperatureC, 0); + u8g2.print(" C"); + + u8g2.setCursor(0, 56); + u8g2.print("Rain "); + u8g2.print(weatherForecastInfo.precipitationProbabilityPercent); + u8g2.print("% "); + u8g2.print(weatherForecastInfo.precipitationMm, 1); + u8g2.print(" mm"); } diff --git a/src/weather_forecast.cpp b/src/weather_forecast.cpp new file mode 100644 index 0000000..de6c581 --- /dev/null +++ b/src/weather_forecast.cpp @@ -0,0 +1,247 @@ +#include "weather_forecast.h" + +#include + +#include "wifi_credentials.h" + +#ifndef WEATHER_LATITUDE +#define WEATHER_LATITUDE 0.0 +#endif + +#ifndef WEATHER_LONGITUDE +#define WEATHER_LONGITUDE 0.0 +#endif + +namespace +{ +const char weatherHost[] = "api.open-meteo.com"; +const unsigned long retryInterval = 300000; +const unsigned long refreshInterval = 3600000; +const size_t forecastHours = 12; +const size_t maxResponseLength = 3500; + +const char *weatherRequestPath() +{ + static String path; + path = "/v1/forecast?latitude="; + path += String(WEATHER_LATITUDE, 4); + path += "&longitude="; + path += String(WEATHER_LONGITUDE, 4); + path += "&hourly=temperature_2m,precipitation_probability,precipitation,weather_code"; + path += "&forecast_hours=12&timezone=auto&forecast_days=1"; + return path.c_str(); +} + +const char *arrayStartForName(const String &body, const char *name) +{ + const String key = String("\"") + name + "\":["; + const int start = body.indexOf(key); + if (start < 0) { + return nullptr; + } + return body.c_str() + start + key.length(); +} + +void skipWhitespace(const char *&cursor) +{ + while (*cursor == ' ' || *cursor == '\n' || *cursor == '\r' || *cursor == '\t') { + cursor++; + } +} +} + +WeatherForecast::WeatherForecast() + : current_({false, 0.0F, 0.0F, 0.0F, 0.0F, 0, 0}), + nextAttempt_(0), + dirty_(true) {} + +void WeatherForecast::update(unsigned long now, bool wifiConnected) +{ + if (!wifiConnected) { + return; + } + + if (now >= nextAttempt_) { + fetch(now); + } +} + +WeatherForecastInfo WeatherForecast::info() const +{ + return current_; +} + +bool WeatherForecast::consumeDirty() +{ + const bool d = dirty_; + dirty_ = false; + return d; +} + +void WeatherForecast::fetch(unsigned long now) +{ + nextAttempt_ = now + retryInterval; + + String body; + if (!readResponse(body)) { + return; + } + + WeatherForecastInfo forecast; + if (!parseForecast(body, forecast)) { + return; + } + + current_ = forecast; + dirty_ = true; + nextAttempt_ = now + refreshInterval; +} + +bool WeatherForecast::readResponse(String &body) +{ + WiFiClient client; + if (!client.connect(weatherHost, 80)) { + return false; + } + + client.print("GET "); + client.print(weatherRequestPath()); + client.println(" HTTP/1.0"); + client.print("Host: "); + client.println(weatherHost); + client.println("Connection: close"); + client.println(); + + const unsigned long started = millis(); + bool inBody = false; + char headerTail[4] = {0, 0, 0, 0}; + while ((client.connected() || client.available()) && millis() - started < 10000) { + while (client.available()) { + const char c = client.read(); + + if (!inBody) { + headerTail[0] = headerTail[1]; + headerTail[1] = headerTail[2]; + headerTail[2] = headerTail[3]; + headerTail[3] = c; + inBody = headerTail[0] == '\r' && + headerTail[1] == '\n' && + headerTail[2] == '\r' && + headerTail[3] == '\n'; + continue; + } + + if (body.length() < maxResponseLength) { + body += c; + } + } + } + + client.stop(); + return body.indexOf("\"hourly\"") >= 0; +} + +bool WeatherForecast::parseForecast(const String &body, WeatherForecastInfo &forecast) const +{ + float temperatures[forecastHours]; + float precipitation[forecastHours]; + int precipitationProbability[forecastHours]; + int weatherCodes[forecastHours]; + size_t temperatureCount = 0; + size_t precipitationCount = 0; + size_t probabilityCount = 0; + size_t weatherCodeCount = 0; + + if (!parseFloatArray(body, "temperature_2m", temperatures, forecastHours, temperatureCount) || + !parseFloatArray(body, "precipitation", precipitation, forecastHours, precipitationCount) || + !parseIntArray(body, "precipitation_probability", precipitationProbability, forecastHours, probabilityCount) || + !parseIntArray(body, "weather_code", weatherCodes, forecastHours, weatherCodeCount)) { + return false; + } + + if (temperatureCount == 0 || + precipitationCount == 0 || + probabilityCount == 0 || + weatherCodeCount == 0) { + return false; + } + + float minTemperature = temperatures[0]; + float maxTemperature = temperatures[0]; + float totalPrecipitation = 0.0F; + uint8_t maxProbability = 0; + + for (size_t i = 0; i < temperatureCount; i++) { + if (temperatures[i] < minTemperature) { + minTemperature = temperatures[i]; + } + if (temperatures[i] > maxTemperature) { + maxTemperature = temperatures[i]; + } + } + + for (size_t i = 0; i < precipitationCount; i++) { + totalPrecipitation += precipitation[i]; + } + + for (size_t i = 0; i < probabilityCount; i++) { + if (precipitationProbability[i] > maxProbability) { + maxProbability = precipitationProbability[i]; + } + } + + forecast = { + true, + temperatures[0], + minTemperature, + maxTemperature, + totalPrecipitation, + maxProbability, + weatherCodes[0] + }; + return true; +} + +bool WeatherForecast::parseFloatArray(const String &body, const char *name, float *values, size_t maxCount, size_t &count) const +{ + const char *cursor = arrayStartForName(body, name); + if (cursor == nullptr) { + return false; + } + + count = 0; + while (*cursor != '\0' && *cursor != ']' && count < maxCount) { + skipWhitespace(cursor); + values[count++] = atof(cursor); + while (*cursor != '\0' && *cursor != ',' && *cursor != ']') { + cursor++; + } + if (*cursor == ',') { + cursor++; + } + } + + return count > 0; +} + +bool WeatherForecast::parseIntArray(const String &body, const char *name, int *values, size_t maxCount, size_t &count) const +{ + const char *cursor = arrayStartForName(body, name); + if (cursor == nullptr) { + return false; + } + + count = 0; + while (*cursor != '\0' && *cursor != ']' && count < maxCount) { + skipWhitespace(cursor); + values[count++] = atoi(cursor); + while (*cursor != '\0' && *cursor != ',' && *cursor != ']') { + cursor++; + } + if (*cursor == ',') { + cursor++; + } + } + + return count > 0; +}