Skip to content

Commit

Permalink
solar_forecast: Add initial solar forecast stub implementation
Browse files Browse the repository at this point in the history
The whole configuration and actual parsing of the data etc is still WIP
  • Loading branch information
borg42 committed Jul 23, 2024
1 parent 1b9a088 commit ddfc73c
Show file tree
Hide file tree
Showing 5 changed files with 346 additions and 0 deletions.
2 changes: 2 additions & 0 deletions software/pio_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);')
Expand Down
1 change: 1 addition & 0 deletions software/platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
6 changes: 6 additions & 0 deletions software/src/modules/solar_forecast/module.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[Dependencies]
Requires = Task Scheduler
Event Log
API

Optional = Certs
274 changes: 274 additions & 0 deletions software/src/modules/solar_forecast/solar_forecast.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/* esp32-firmware
* Copyright (C) 2024 Olaf Lüke <[email protected]>
*
* 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 <time.h>
#include <lwip/inet.h>

#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<SolarForecast *>(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<uint8_t>(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();
}
63 changes: 63 additions & 0 deletions software/src/modules/solar_forecast/solar_forecast.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* esp32-firmware
* Copyright (C) 2024 Olaf Lüke <[email protected]>
*
* 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 <FS.h> // FIXME: without this include here there is a problem with the IPADDR_NONE define in <lwip/ip4_addr.h>
#include <esp_http_client.h>
#include <ArduinoJson.h>

#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<unsigned char[]> 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;
};

0 comments on commit ddfc73c

Please sign in to comment.