diff --git a/software/pio_hooks.py b/software/pio_hooks.py index 899c41ccc..a455aaa0e 100644 --- a/software/pio_hooks.py +++ b/software/pio_hooks.py @@ -482,6 +482,7 @@ def main(): firmware_url = env.GetProjectOption("custom_firmware_url") firmware_update_url = env.GetProjectOption("custom_firmware_update_url") day_ahead_price_api_url = env.GetProjectOption("custom_day_ahead_price_api_url") + solar_forecast_api_url = env.GetProjectOption("custom_solar_forecast_api_url") require_firmware_info = env.GetProjectOption("custom_require_firmware_info") build_flags = env.GetProjectOption("build_flags") frontend_debug = env.GetProjectOption("custom_frontend_debug") == "true" @@ -608,6 +609,7 @@ def main(): build_lines.append('#define BUILD_MONITOR_SPEED {}'.format(monitor_speed)) build_lines.append('#define BUILD_FIRMWARE_UPDATE_URL "{}"'.format(firmware_update_url)) build_lines.append('#define BUILD_DAY_AHEAD_PRICE_API_URL "{}"'.format(day_ahead_price_api_url)) + build_lines.append('#define BUILD_SOLAR_FORECAST_API_URL "{}"'.format(solar_forecast_api_url)) build_lines.append('uint32_t build_timestamp(void);') build_lines.append('const char *build_timestamp_hex_str(void);') build_lines.append('const char *build_version_full_str(void);') diff --git a/software/platformio.ini b/software/platformio.ini index 7e3ed77be..aa376842d 100644 --- a/software/platformio.ini +++ b/software/platformio.ini @@ -41,6 +41,7 @@ build_flags = -DTF_NET_ENABLE=1 custom_manufacturer = Tinkerforge GmbH custom_firmware_update_url = custom_day_ahead_price_api_url = https://api.warp-charger.com/ +custom_solar_forecast_api_url = https://api.forecast.solar/ custom_frontend_debug = false custom_web_only = false custom_web_build_flags = diff --git a/software/src/modules/solar_forecast/module.ini b/software/src/modules/solar_forecast/module.ini new file mode 100644 index 000000000..ee4ffaf41 --- /dev/null +++ b/software/src/modules/solar_forecast/module.ini @@ -0,0 +1,6 @@ +[Dependencies] +Requires = Task Scheduler + Event Log + API + +Optional = Certs \ No newline at end of file diff --git a/software/src/modules/solar_forecast/solar_forecast.cpp b/software/src/modules/solar_forecast/solar_forecast.cpp new file mode 100644 index 000000000..977747df3 --- /dev/null +++ b/software/src/modules/solar_forecast/solar_forecast.cpp @@ -0,0 +1,274 @@ +/* esp32-firmware + * Copyright (C) 2024 Olaf Lüke + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the + * Free Software Foundation, Inc., 59 Temple Place - Suite 330, + * Boston, MA 02111-1307, USA. + */ +#include "solar_forecast.h" + +#include +#include + +#include "event_log_prefix.h" +#include "module_dependencies.h" +#include "build.h" + +extern "C" esp_err_t esp_crt_bundle_attach(void *conf); + +#define CHECK_FOR_SF_TIMEOUT 15000 +#define CHECK_INTERVAL 15*60*1000 + +extern SolarForecast dap; + +void SolarForecast::pre_setup() +{ + config = ConfigRoot{Config::Object({ + {"enable", Config::Bool(true)}, + {"api_url", Config::Str(BUILD_SOLAR_FORECAST_API_URL, 0, 64)}, + {"cert_id", Config::Int(-1, -1, MAX_CERT_ID)}, + }), [this](Config &update, ConfigSource source) -> String { + String api_url = update.get("api_url")->asString(); + + if ((api_url.length() > 0) && !api_url.startsWith("https://")) { + return "HTTPS required for Day Ahead Price API URL"; + } + + return ""; + }}; + + state = Config::Object({ + {"last_sync", Config::Uint32(0)}, // unix timestamp in minutes + {"last_check", Config::Uint32(0)}, // unix timestamp in minutes + {"next_check", Config::Uint32(0)} // unix timestamp in minutes + }); + + forecast = Config::Object({ + }); +} + +void SolarForecast::setup() +{ + api.restorePersistentConfig("solar_forecast/config", &config); + + json_buffer = nullptr; + json_buffer_position = 0; + + initialized = true; +} + +void SolarForecast::register_urls() +{ + api.addPersistentConfig("solar_forecast/config", &config); + api.addState("solar_forecast/state", &state); + api.addState("solar_forecast/forecast", &forecast); + + task_scheduler.scheduleWithFixedDelay([this]() { + this->update(); + }, 0, CHECK_INTERVAL); +} + +esp_err_t SolarForecast::update_event_handler_impl(esp_http_client_event_t *event) +{ + if (download_complete) { + return ESP_OK; + } + + switch (event->event_id) { + case HTTP_EVENT_ERROR: { + logger.printfln("HTTP error while downloading json"); + download_state = SF_DOWNLOAD_STATE_ERROR; + download_complete = true; + break; + } + + case HTTP_EVENT_ON_DATA: { + int code = esp_http_client_get_status_code(http_client); + // Check status code + if (code != 200) { + logger.printfln("HTTP error while downloading json: %d", code); + download_state = SF_DOWNLOAD_STATE_ERROR; + download_complete = true; + break; + } + + // Check length + if((event->data_len + json_buffer_position) > (SOLAR_FORECAST_MAX_JSON_LENGTH - 1)) { + logger.printfln("JSON buffer too small"); + download_state = SF_DOWNLOAD_STATE_ERROR; + download_complete = true; + break; + } + + // Copy data to temporary buffer + memcpy(json_buffer + json_buffer_position, event->data, event->data_len); + json_buffer_position += event->data_len; + break; + } + + case HTTP_EVENT_ON_FINISH: + json_buffer[json_buffer_position] = '\0'; + download_complete = true; + break; + + default: + break; + } + + return ESP_OK; +} + +static esp_err_t update_event_handler(esp_http_client_event_t *event) +{ + return static_cast(event->user_data)->update_event_handler_impl(event); +} + +void SolarForecast::update() +{ + if (http_client != nullptr) { + return; + } + + if (state.get("next_check")->asUint() > timestamp_minutes()) { + return; + } + + if (config.get("enable")->asBool() == false) { + return; + } + + download_state = SF_DOWNLOAD_STATE_PENDING; + state.get("last_check")->updateUint(timestamp_minutes()); + + if (config.get("api_url")->asString().length() == 0) { + logger.printfln("No day ahead price API server configured"); + download_state = SF_DOWNLOAD_STATE_ERROR; + return; + } + + esp_http_client_config_t http_config = {}; + + http_config.url = get_api_url_with_path(); + http_config.event_handler = update_event_handler; + http_config.user_data = this; + http_config.is_async = true; + http_config.timeout_ms = 500; + + const int cert_id = config.get("cert_id")->asInt(); + + if (cert_id < 0) { + http_config.crt_bundle_attach = esp_crt_bundle_attach; + } + else { +#if MODULE_CERTS_AVAILABLE() + size_t cert_len = 0; + + cert = certs.get_cert(static_cast(cert_id), &cert_len); + + if (cert == nullptr) { + logger.printfln("Certificate with ID %d is not available", cert_id); + download_state = SF_DOWNLOAD_STATE_ERROR; + return; + } + + http_config.cert_pem = (const char *)cert.get(); +#else + // defense in depth: it should not be possible to arrive here because in case + // that the certs module is not available the cert_id should always be -1 + logger.printfln("Can't use custom certitifate: certs module is not built into this firmware!"); + return; +#endif + } + + http_client = esp_http_client_init(&http_config); + + if (http_client == nullptr) { + logger.printfln("Error while creating HTTP client"); + cert.reset(); + return; + } + + last_update_begin = millis(); + + if(json_buffer == nullptr) { + json_buffer = (char *)heap_caps_calloc_prefer(SOLAR_FORECAST_MAX_JSON_LENGTH, sizeof(char), 2, MALLOC_CAP_SPIRAM, MALLOC_CAP_8BIT | MALLOC_CAP_INTERNAL); + } else { + logger.printfln("JSON Buffer was potentially not freed correctly"); + json_buffer_position = 0; + } + + // Start async JSON download and check every 100ms + download_complete = false; + task_scheduler.scheduleWithFixedDelay([this]() { + // Check for global timeout + if (deadline_elapsed(last_update_begin + CHECK_FOR_SF_TIMEOUT)) { + logger.printfln("API server %s did not respond in time", config.get("api_url")->asString().c_str()); + download_state = SF_DOWNLOAD_STATE_ERROR; + download_complete = true; + } + + if (!download_complete) { + // If download is not complete start a new download + esp_err_t err = esp_http_client_perform(http_client); + + if (err == ESP_ERR_HTTP_EAGAIN) { + // Nothing to do, just wait for more data + } else if (err != ESP_OK) { + logger.printfln("Error while downloading json: %s", esp_err_to_name(err)); + download_state = SF_DOWNLOAD_STATE_ERROR; + download_complete = true; + } else if (download_state == SF_DOWNLOAD_STATE_PENDING) { + // If we reach here the download finished and no error occurred during the download + download_state = SF_DOWNLOAD_STATE_OK; + download_complete = true; + } + } + + if (download_complete) { + if(download_state == SF_DOWNLOAD_STATE_OK) { + // Deserialize json received from API + json_buffer[json_buffer_position] = 0; + logger.printfln("buffer: %s", json_buffer); + } + + // Cleanup + esp_http_client_close(http_client); + esp_http_client_cleanup(http_client); + http_client = nullptr; + cert.reset(); + heap_caps_free(json_buffer); + json_buffer = nullptr; + json_buffer_position = 0; + + task_scheduler.cancel(task_scheduler.currentTaskId()); + } + + }, 100, 100); +} + +// Create API path including user configuration +const char* SolarForecast::get_api_url_with_path() +{ + static String api_url_with_path; + + String api_url = config.get("api_url")->asString(); + + if (!api_url.endsWith("/")) { + api_url += "/"; + } + + // TODO + + return api_url_with_path.c_str(); +} \ No newline at end of file diff --git a/software/src/modules/solar_forecast/solar_forecast.h b/software/src/modules/solar_forecast/solar_forecast.h new file mode 100644 index 000000000..b039d1da9 --- /dev/null +++ b/software/src/modules/solar_forecast/solar_forecast.h @@ -0,0 +1,63 @@ +/* esp32-firmware + * Copyright (C) 2024 Olaf Lüke + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the + * Free Software Foundation, Inc., 59 Temple Place - Suite 330, + * Boston, MA 02111-1307, USA. + */ + +#pragma once + +#include // FIXME: without this include here there is a problem with the IPADDR_NONE define in +#include +#include + +#include "module.h" +#include "config.h" + +#define SOLAR_FORECAST_MAX_JSON_LENGTH 4096*4 // TODO: How big does this need to be? + +enum SFDownloadState { + SF_DOWNLOAD_STATE_OK, + SF_DOWNLOAD_STATE_PENDING, + SF_DOWNLOAD_STATE_ERROR +}; + +class SolarForecast final : public IModule +{ +private: + void update(); + void update_price(); + const char* get_api_url_with_path(); + + std::unique_ptr cert = nullptr; + esp_http_client_handle_t http_client = nullptr; + uint32_t last_update_begin; + bool download_complete; + char *json_buffer; + uint32_t json_buffer_position; + + SFDownloadState download_state = SF_DOWNLOAD_STATE_OK; + +public: + SolarForecast(){} + void pre_setup() override; + void setup() override; + void register_urls() override; + esp_err_t update_event_handler_impl(esp_http_client_event_t *event); + + ConfigRoot config; + ConfigRoot state; + ConfigRoot forecast; +};