chore: snapshot before shared RS485 bus integration

This commit is contained in:
2026-07-22 15:36:04 +01:00
parent ef02e5c5f5
commit 2a803fcef1
167 changed files with 5749 additions and 1128 deletions

2
components/rest_api/CMakeLists.txt Executable file → Normal file
View File

@@ -5,7 +5,7 @@ set(srcs
"src/auth_api.c"
"src/network_api.c"
"src/meters_settings_api.c"
"src/loadbalancing_settings_api.c"
"src/power_settings_api.c"
"src/evse_link_config_api.c"
"src/dashboard_api.c"
"src/scheduler_settings_api.c"

0
components/rest_api/include/auth_api.h Executable file → Normal file
View File

0
components/rest_api/include/evse_settings_api.h Executable file → Normal file
View File

0
components/rest_api/include/network_api.h Executable file → Normal file
View File

0
components/rest_api/include/ocpp_api.h Executable file → Normal file
View File

View File

@@ -0,0 +1,23 @@
#ifndef POWER_SETTINGS_API_H_
#define POWER_SETTINGS_API_H_
#ifdef __cplusplus
extern "C" {
#endif
#include "esp_http_server.h"
/**
* @brief Register power configuration REST handlers.
*
* Endpoint:
* GET /api/v1/config/power
* POST /api/v1/config/power
*/
void register_power_settings_handlers(httpd_handle_t server, void *ctx);
#ifdef __cplusplus
}
#endif
#endif /* POWER_SETTINGS_API_H_ */

0
components/rest_api/include/rest_main.h Executable file → Normal file
View File

0
components/rest_api/include/scheduler_settings_api.h Executable file → Normal file
View File

0
components/rest_api/src/auth_api.c Executable file → Normal file
View File

73
components/rest_api/src/evse_settings_api.c Executable file → Normal file
View File

@@ -8,61 +8,93 @@
#include "esp_log.h"
#include "cJSON.h"
#include <stdlib.h>
static const char *TAG = "evse_settings_api";
static esp_err_t config_settings_get_handler(httpd_req_t *req) {
static esp_err_t config_settings_get_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "application/json");
cJSON *config = cJSON_CreateObject();
if (!config)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No mem");
return ESP_FAIL;
}
/*
* currentLimit deixou de pertencer a /config/settings.
* A corrente manual/static é configurada em /api/v1/config/power:
*
* {
* "mode": "manual",
* "manual": { "currentA": 16 }
* }
*
* maxCurrentLimit fica aqui como informação read-only para a UI.
*/
cJSON_AddNumberToObject(config, "maxCurrentLimit", evse_get_max_charging_current());
cJSON_AddNumberToObject(config, "currentLimit", evse_get_charging_current());
cJSON_AddNumberToObject(config, "powerLimit", 0);
cJSON_AddNumberToObject(config, "energyLimit", evse_get_consumption_limit());
cJSON_AddNumberToObject(config, "chargingTimeLimit", evse_get_charging_time_limit());
cJSON_AddNumberToObject(config, "temperatureLimit", evse_get_temp_threshold());
cJSON *security = cJSON_CreateObject();
cJSON_AddBoolToObject(security, "earthFault", false);
cJSON_AddBoolToObject(security, "rcm", false);
cJSON_AddItemToObject(config, "security", security);
if (security)
{
cJSON_AddBoolToObject(security, "earthFault", false);
cJSON_AddBoolToObject(security, "rcm", false);
cJSON_AddItemToObject(config, "security", security);
}
char *json_str = cJSON_PrintUnformatted(config);
httpd_resp_sendstr(req, json_str ? json_str : "{}");
if (json_str) free(json_str);
if (json_str)
free(json_str);
cJSON_Delete(config);
return ESP_OK;
}
static esp_err_t config_settings_post_handler(httpd_req_t *req) {
static esp_err_t config_settings_post_handler(httpd_req_t *req)
{
char buf[512];
int len = httpd_req_recv(req, buf, sizeof(buf) - 1);
if (len <= 0) {
if (len <= 0)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
return ESP_FAIL;
}
buf[len] = '\0';
cJSON *json = cJSON_Parse(buf);
if (!json) {
if (!json)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
return ESP_FAIL;
}
cJSON *current = cJSON_GetObjectItem(json, "currentLimit");
if (cJSON_IsNumber(current)) {
int value = current->valueint;
int hw_max = evse_get_max_charging_current();
if (value < 0) value = 0;
if (value > hw_max) value = hw_max;
evse_set_charging_current(value);
/*
* currentLimit é intencionalmente ignorado neste endpoint.
* A UI nova não o envia. Se algum cliente antigo o enviar,
* não altera a corrente para evitar duas fontes de verdade.
*/
if (cJSON_GetObjectItem(json, "currentLimit"))
{
ESP_LOGW(TAG, "Ignoring legacy currentLimit in /config/settings; use /config/power instead");
}
cJSON *temp = cJSON_GetObjectItem(json, "temperatureLimit");
if (cJSON_IsNumber(temp)) {
if (cJSON_IsNumber(temp))
{
evse_set_temp_threshold(temp->valueint);
}
// Campos aceites por compatibilidade de contrato v1.
// Nesta sprint são ignorados caso o firmware ainda não os suporte internamente.
/*
* Campos aceites por compatibilidade de contrato v1.
* Continuam ignorados se o firmware ainda não os suportar internamente.
*/
(void)cJSON_GetObjectItem(json, "powerLimit");
(void)cJSON_GetObjectItem(json, "energyLimit");
(void)cJSON_GetObjectItem(json, "chargingTimeLimit");
@@ -73,7 +105,8 @@ static esp_err_t config_settings_post_handler(httpd_req_t *req) {
return ESP_OK;
}
void register_evse_settings_handlers(httpd_handle_t server, void *ctx) {
void register_evse_settings_handlers(httpd_handle_t server, void *ctx)
{
httpd_uri_t get_uri = {
.uri = "/api/v1/config/settings",
.method = HTTP_GET,

View File

@@ -8,15 +8,47 @@
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <stdint.h>
static const char *TAG = "loadbalancing_settings_api";
// limites simples
/*
* REST API contract
*
* GET /api/v1/config/loadbalancing
*
* {
* "mode": "off" | "manual" | "grid" | "solar",
* "gridLimit": {
* "enabled": true,
* "maxImportA": 30
* },
* "solar": {
* "maxImportW": 0
* }
* }
*
* Notes:
* - "mode" is the source of truth.
* - There is no top-level "enabled"; it is derived from mode != "off".
* - There is no "solar.enabled"; it is derived from mode == "solar".
* - There is no "solar.useGridLimit"; use gridLimit.enabled instead.
* - In mode "solar", gridLimit.enabled=true means solar with grid import cap.
* - In mode "off" or "manual", gridLimit/solar values are kept as saved config.
*
* Compatibility:
* - POST still accepts legacy "enabled".
* - POST still accepts legacy "pv" as alias for "solar".
*/
#define MIN_GRID_A 6
#define MAX_GRID_A 100
#define MIN_PV_W 0
#define MAX_PV_W 100000 // ajusta se quiseres
#define MAX_PV_W 100000
#define REQ_BODY_MAX 768
static esp_err_t send_json(httpd_req_t *req, cJSON *root)
{
@@ -25,7 +57,9 @@ static esp_err_t send_json(httpd_req_t *req, cJSON *root)
char *json_str = cJSON_PrintUnformatted(root);
if (!json_str)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "JSON encode failed");
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"JSON encode failed");
return ESP_FAIL;
}
@@ -34,38 +68,11 @@ static esp_err_t send_json(httpd_req_t *req, cJSON *root)
return err;
}
// GET -> payload novo
static esp_err_t loadbalancing_config_get_handler(httpd_req_t *req)
{
cJSON *root = cJSON_CreateObject();
if (!root)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "No mem");
return ESP_FAIL;
}
cJSON_AddBoolToObject(root, "enabled", loadbalancer_is_enabled());
cJSON *grid = cJSON_CreateObject();
cJSON_AddItemToObject(root, "gridLimit", grid);
cJSON_AddBoolToObject(grid, "enabled", loadbalancer_grid_is_enabled());
cJSON_AddNumberToObject(grid, "maxImportA", loadbalancer_grid_get_max_import_a());
cJSON *pv = cJSON_CreateObject();
cJSON_AddItemToObject(root, "pv", pv);
cJSON_AddBoolToObject(pv, "enabled", loadbalancer_pv_is_enabled());
cJSON_AddNumberToObject(pv, "maxImportW", loadbalancer_pv_get_max_import_w());
esp_err_t err = send_json(req, root);
cJSON_Delete(root);
return err;
}
// lê body de forma robusta (httpd_req_recv pode devolver parcial)
static esp_err_t read_body(httpd_req_t *req, char *buf, size_t buf_sz)
{
if (req->content_len <= 0)
return ESP_FAIL;
if ((size_t)req->content_len >= buf_sz)
return ESP_ERR_NO_MEM;
@@ -77,6 +84,7 @@ static esp_err_t read_body(httpd_req_t *req, char *buf, size_t buf_sz)
int r = httpd_req_recv(req, buf + off, remaining);
if (r <= 0)
return ESP_FAIL;
off += r;
remaining -= r;
}
@@ -85,31 +93,223 @@ static esp_err_t read_body(httpd_req_t *req, char *buf, size_t buf_sz)
return ESP_OK;
}
// POST -> updates parciais aceites
static bool mode_is_enabled(loadbalancer_limit_mode_t mode)
{
return mode != LOADBALANCER_LIMIT_MODE_OFF;
}
static const char *rest_limit_mode_to_str(loadbalancer_limit_mode_t mode)
{
switch (mode)
{
case LOADBALANCER_LIMIT_MODE_OFF:
return "off";
case LOADBALANCER_LIMIT_MODE_MANUAL:
return "manual";
case LOADBALANCER_LIMIT_MODE_GRID:
return "grid";
case LOADBALANCER_LIMIT_MODE_SOLAR:
return "solar";
default:
return "off";
}
}
static bool rest_limit_mode_from_str(const char *str,
loadbalancer_limit_mode_t *out)
{
if (!str || !out)
return false;
if (strcmp(str, "off") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_OFF;
return true;
}
if (strcmp(str, "manual") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_MANUAL;
return true;
}
if (strcmp(str, "grid") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_GRID;
return true;
}
if (strcmp(str, "solar") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_SOLAR;
return true;
}
/*
* Aliases legacy.
*/
if (strcmp(str, "pv") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_SOLAR;
return true;
}
if (strcmp(str, "loadbalancer") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_GRID;
return true;
}
/*
* Fallback para parser do módulo, caso tenha aliases adicionais.
*/
return loadbalancer_limit_mode_from_str(str, out);
}
/*
* Compatibilidade com payload antigo:
*
* {
* "enabled": true,
* "gridLimit": { "enabled": true },
* "pv": { "enabled": false }
* }
*/
static loadbalancer_limit_mode_t derive_mode_from_legacy(bool enabled,
bool grid_enabled,
bool solar_enabled)
{
if (!enabled)
return LOADBALANCER_LIMIT_MODE_OFF;
/*
* SOLAR tem prioridade.
* Em modo SOLAR, gridLimit.enabled pode continuar true
* para limitar a importação da rede.
*/
if (solar_enabled)
return LOADBALANCER_LIMIT_MODE_SOLAR;
if (grid_enabled)
return LOADBALANCER_LIMIT_MODE_GRID;
/*
* Compatibilidade com UI antiga:
* se vier só {"enabled": true}, assume GRID.
*/
return LOADBALANCER_LIMIT_MODE_GRID;
}
// GET -> payload novo e limpo, baseado em mode
static esp_err_t loadbalancing_config_get_handler(httpd_req_t *req)
{
cJSON *root = cJSON_CreateObject();
if (!root)
{
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
loadbalancer_limit_mode_t mode = loadbalancer_get_limit_mode();
/*
* Campo principal.
*/
cJSON_AddStringToObject(root, "mode", rest_limit_mode_to_str(mode));
/*
* Configuração do limite de importação da rede.
*
* Em mode=grid:
* gridLimit.enabled deve estar true.
*
* Em mode=solar:
* gridLimit.enabled=true significa solar com limite máximo
* de importação da rede.
*/
cJSON *grid = cJSON_CreateObject();
if (!grid)
{
cJSON_Delete(root);
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
cJSON_AddItemToObject(root, "gridLimit", grid);
cJSON_AddBoolToObject(grid,
"enabled",
loadbalancer_grid_is_enabled());
cJSON_AddNumberToObject(grid,
"maxImportA",
loadbalancer_grid_get_max_import_a());
/*
* Novo bloco REST: solar.
* Internamente ainda usa funções pv_*.
*/
cJSON *solar = cJSON_CreateObject();
if (!solar)
{
cJSON_Delete(root);
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
cJSON_AddItemToObject(root, "solar", solar);
cJSON_AddNumberToObject(solar,
"maxImportW",
loadbalancer_pv_get_max_import_w());
esp_err_t err = send_json(req, root);
cJSON_Delete(root);
return err;
}
// POST -> updates parciais aceites, mas "mode" é o campo principal
static esp_err_t loadbalancing_config_post_handler(httpd_req_t *req)
{
if (req->content_len <= 0)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Empty body");
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Empty body");
return ESP_FAIL;
}
if (req->content_len >= 512)
if (req->content_len >= REQ_BODY_MAX)
{
httpd_resp_send_err(req, HTTPD_413_CONTENT_TOO_LARGE, "Body too large");
httpd_resp_send_err(req,
HTTPD_413_CONTENT_TOO_LARGE,
"Body too large");
return ESP_FAIL;
}
char buf[512];
char buf[REQ_BODY_MAX];
esp_err_t rb = read_body(req, buf, sizeof(buf));
if (rb == ESP_ERR_NO_MEM)
{
httpd_resp_send_err(req, HTTPD_413_CONTENT_TOO_LARGE, "Body too large");
httpd_resp_send_err(req,
HTTPD_413_CONTENT_TOO_LARGE,
"Body too large");
return ESP_FAIL;
}
if (rb != ESP_OK)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read body");
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Failed to read body");
return ESP_FAIL;
}
@@ -118,81 +318,419 @@ static esp_err_t loadbalancing_config_post_handler(httpd_req_t *req)
cJSON *json = cJSON_Parse(buf);
if (!json)
{
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON");
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Invalid JSON");
return ESP_FAIL;
}
// enabled (top-level)
cJSON *enabled_item = cJSON_GetObjectItem(json, "enabled");
if (enabled_item && cJSON_IsBool(enabled_item))
if (!cJSON_IsObject(json))
{
loadbalancer_set_enabled(cJSON_IsTrue(enabled_item));
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"JSON root must be object");
return ESP_FAIL;
}
// gridLimit
cJSON *grid = cJSON_GetObjectItem(json, "gridLimit");
if (grid && cJSON_IsObject(grid))
/*
* Base = estado atual.
* Isto permite updates parciais sem limpar configuração.
*/
loadbalancer_limit_mode_t next_mode = loadbalancer_get_limit_mode();
bool next_grid_enabled = loadbalancer_grid_is_enabled();
uint8_t next_grid_max_a = loadbalancer_grid_get_max_import_a();
/*
* Internamente o módulo ainda usa pv_*.
* Na REST nova chamamos "solar".
*/
bool next_solar_enabled = loadbalancer_pv_is_enabled();
int32_t next_solar_max_w = loadbalancer_pv_get_max_import_w();
bool mode_provided = false;
/*
* Campos legacy só são usados para derivar mode se "mode"
* não vier no payload.
*/
bool legacy_operational_hint = false;
bool legacy_enabled = mode_is_enabled(next_mode);
/*
* Campo novo/principal:
*
* {
* "mode": "off" | "manual" | "grid" | "solar"
* }
*/
cJSON *mode_item = cJSON_GetObjectItem(json, "mode");
if (mode_item)
{
cJSON *g_en = cJSON_GetObjectItem(grid, "enabled");
if (g_en && cJSON_IsBool(g_en))
if (!cJSON_IsString(mode_item) || !mode_item->valuestring)
{
loadbalancer_grid_set_enabled(cJSON_IsTrue(g_en));
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"mode must be string");
return ESP_FAIL;
}
if (!rest_limit_mode_from_str(mode_item->valuestring, &next_mode))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Invalid mode");
return ESP_FAIL;
}
mode_provided = true;
}
/*
* Campo legacy:
*
* {
* "enabled": true
* }
*
* Não é emitido pelo GET novo.
* Só é aceite para compatibilidade.
*/
cJSON *enabled_item = cJSON_GetObjectItem(json, "enabled");
if (enabled_item)
{
if (!cJSON_IsBool(enabled_item))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"enabled must be boolean");
return ESP_FAIL;
}
legacy_enabled = cJSON_IsTrue(enabled_item);
legacy_operational_hint = true;
}
/*
* gridLimit
*/
cJSON *grid = cJSON_GetObjectItem(json, "gridLimit");
if (grid)
{
if (!cJSON_IsObject(grid))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"gridLimit must be object");
return ESP_FAIL;
}
cJSON *g_en = cJSON_GetObjectItem(grid, "enabled");
if (g_en)
{
if (!cJSON_IsBool(g_en))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"gridLimit.enabled must be boolean");
return ESP_FAIL;
}
next_grid_enabled = cJSON_IsTrue(g_en);
legacy_operational_hint = true;
}
cJSON *g_maxA = cJSON_GetObjectItem(grid, "maxImportA");
if (g_maxA && cJSON_IsNumber(g_maxA))
if (g_maxA)
{
if (!cJSON_IsNumber(g_maxA))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"gridLimit.maxImportA must be number");
return ESP_FAIL;
}
int maxA = (int)g_maxA->valuedouble;
if (maxA < MIN_GRID_A || maxA > MAX_GRID_A)
{
cJSON_Delete(json);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "gridLimit.maxImportA must be 6-100");
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"gridLimit.maxImportA must be 6-100");
return ESP_FAIL;
}
esp_err_t e = loadbalancer_grid_set_max_import_a((uint8_t)maxA);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to set gridLimit.maxImportA");
return ESP_FAIL;
}
next_grid_max_a = (uint8_t)maxA;
}
}
// pv
cJSON *pv = cJSON_GetObjectItem(json, "pv");
if (pv && cJSON_IsObject(pv))
/*
* Bloco novo: solar
*
* {
* "solar": {
* "maxImportW": 0
* }
* }
*
* Compatibilidade:
* ainda aceita "pv" como alias antigo de "solar".
*/
cJSON *solar = cJSON_GetObjectItem(json, "solar");
if (!solar)
solar = cJSON_GetObjectItem(json, "pv");
if (solar)
{
cJSON *p_en = cJSON_GetObjectItem(pv, "enabled");
if (p_en && cJSON_IsBool(p_en))
if (!cJSON_IsObject(solar))
{
loadbalancer_pv_set_enabled(cJSON_IsTrue(p_en));
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar must be object");
return ESP_FAIL;
}
cJSON *p_maxW = cJSON_GetObjectItem(pv, "maxImportW");
if (p_maxW && cJSON_IsNumber(p_maxW))
/*
* Campo legacy:
*
* {
* "solar": { "enabled": true }
* }
*
* Não é emitido pelo GET novo.
* No contrato novo, quem ativa solar é:
*
* {
* "mode": "solar"
* }
*/
cJSON *s_en = cJSON_GetObjectItem(solar, "enabled");
if (s_en)
{
int32_t maxW = (int32_t)p_maxW->valuedouble;
if (!cJSON_IsBool(s_en))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar.enabled must be boolean");
return ESP_FAIL;
}
next_solar_enabled = cJSON_IsTrue(s_en);
legacy_operational_hint = true;
}
/*
* Campo legacy removido do GET:
*
* {
* "solar": { "useGridLimit": true }
* }
*
* Continua aceite no POST e mapeia para gridLimit.enabled.
*/
cJSON *s_use_grid = cJSON_GetObjectItem(solar, "useGridLimit");
if (s_use_grid)
{
if (!cJSON_IsBool(s_use_grid))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar.useGridLimit must be boolean");
return ESP_FAIL;
}
next_grid_enabled = cJSON_IsTrue(s_use_grid);
}
cJSON *s_maxW = cJSON_GetObjectItem(solar, "maxImportW");
if (s_maxW)
{
if (!cJSON_IsNumber(s_maxW))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar.maxImportW must be number");
return ESP_FAIL;
}
int32_t maxW = (int32_t)s_maxW->valuedouble;
if (maxW < MIN_PV_W || maxW > MAX_PV_W)
{
cJSON_Delete(json);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "pv.maxImportW out of range");
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar.maxImportW out of range");
return ESP_FAIL;
}
esp_err_t e = loadbalancer_pv_set_max_import_w(maxW);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to set pv.maxImportW");
return ESP_FAIL;
}
next_solar_max_w = maxW;
}
}
/*
* Se o payload não trouxe "mode", aceitamos a semântica antiga
* e derivamos o modo a partir de enabled/grid/pv.
*
* Se o payload só alterou maxImportA ou maxImportW,
* mantemos o modo atual.
*/
if (!mode_provided && legacy_operational_hint)
{
next_mode = derive_mode_from_legacy(legacy_enabled,
next_grid_enabled,
next_solar_enabled);
}
/*
* Normalização.
*
* "mode" é a fonte de verdade.
* gridLimit e solar são configuração auxiliar.
*/
switch (next_mode)
{
case LOADBALANCER_LIMIT_MODE_OFF:
/*
* OFF não apaga gridLimit nem solar.
* Apenas o modo operacional fica OFF.
*/
break;
case LOADBALANCER_LIMIT_MODE_MANUAL:
/*
* MANUAL não usa algoritmo dinâmico.
* Não apagar configuração grid/solar.
*/
break;
case LOADBALANCER_LIMIT_MODE_GRID:
/*
* GRID implica grid limiter ativo.
* Não precisa de solar ativo.
*/
next_grid_enabled = true;
next_solar_enabled = false;
break;
case LOADBALANCER_LIMIT_MODE_SOLAR:
/*
* SOLAR implica solar ativo.
* Não forçar grid=false.
*
* Em modo solar:
* gridLimit.enabled=true
* significa:
* solar com limite máximo de importação da rede.
*/
next_solar_enabled = true;
break;
default:
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Invalid mode");
return ESP_FAIL;
}
esp_err_t e;
/*
* 1) Parâmetros.
*/
e = loadbalancer_grid_set_max_import_a(next_grid_max_a);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set gridLimit.maxImportA: %s",
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set gridLimit.maxImportA");
return ESP_FAIL;
}
e = loadbalancer_pv_set_max_import_w(next_solar_max_w);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set solar.maxImportW: %s",
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set solar.maxImportW");
return ESP_FAIL;
}
/*
* 2) Configuração auxiliar.
*
* Estes setters guardam opções auxiliares.
* O estado operacional principal continua a ser "mode".
*/
loadbalancer_grid_set_enabled(next_grid_enabled);
loadbalancer_pv_set_enabled(next_solar_enabled);
/*
* 3) Estado operacional principal.
*
* Isto resolve o problema após reset:
* o REST passa a gravar o mesmo campo que o loadbalancer_load_config()
* usa no boot.
*/
e = loadbalancer_set_limit_mode(next_mode);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set loadbalancer mode: %s",
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set loadbalancer mode");
return ESP_FAIL;
}
/*
* 4) Garantir persistência antes de responder OK.
*/
e = loadbalancer_flush_config(2000);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to persist loadbalancer config: %s",
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to persist loadbalancer config");
return ESP_FAIL;
}
ESP_LOGW(TAG,
"Loadbalancing saved: mode=%s gridEnabled=%d maxA=%u solarEnabled=%d solarMaxW=%ld",
rest_limit_mode_to_str(next_mode),
next_grid_enabled,
(unsigned)next_grid_max_a,
next_solar_enabled,
(long)next_solar_max_w);
cJSON_Delete(json);
httpd_resp_sendstr(req, "OK");
return ESP_OK;
@@ -204,13 +742,17 @@ void register_loadbalancing_settings_handlers(httpd_handle_t server, void *ctx)
.uri = "/api/v1/config/loadbalancing",
.method = HTTP_GET,
.handler = loadbalancing_config_get_handler,
.user_ctx = ctx};
.user_ctx = ctx
};
httpd_register_uri_handler(server, &get_uri);
httpd_uri_t post_uri = {
.uri = "/api/v1/config/loadbalancing",
.method = HTTP_POST,
.handler = loadbalancing_config_post_handler,
.user_ctx = ctx};
.user_ctx = ctx
};
httpd_register_uri_handler(server, &post_uri);
}
}

0
components/rest_api/src/network_api.c Executable file → Normal file
View File

0
components/rest_api/src/ocpp_api.c Executable file → Normal file
View File

View File

@@ -0,0 +1,692 @@
#include "power_settings_api.h"
#include "loadbalancer.h"
#include "evse_config.h"
#include "esp_err.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "cJSON.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
static const char *TAG = "power_settings_api";
/*
* REST API contract
*
* Endpoint:
* GET /api/v1/config/power
* POST /api/v1/config/power
*
* JSON:
*
* {
* "mode": "off" | "manual" | "grid" | "solar" | "grid_solar",
* "manual": {
* "currentA": 16
* },
* "grid": {
* "maxImportA": 30
* },
* "solar": {
* "maxImportW": 0
* }
* }
*
* Notes:
* - "mode" is the source of truth.
* - There is no top-level "enabled".
* - There is no "solar.enabled".
* - There is no "solar.gridCap" in the clean API.
* - "manual.currentA" is the static/default charging current.
* - "grid.maxImportA" is the maximum grid import current.
* - "solar.maxImportW" is the maximum allowed import while in solar mode.
*
* Mode behavior:
* - off:
* no dynamic power policy is active.
*
* - manual:
* no dynamic power policy is active; EVSE uses manual.currentA.
*
* - grid:
* grid import limiter is active and uses grid.maxImportA.
*
* - solar:
* solar/surplus optimizer is active and uses solar.maxImportW.
* The grid limiter is not active.
*
* - grid_solar:
* solar/surplus optimizer is active and also respects grid.maxImportA.
*
* Compatibility:
* - POST still accepts legacy solar.gridCap. If mode=solar and gridCap=true,
* it is converted to mode=grid_solar.
*/
#define MIN_MANUAL_A 6
#define MIN_GRID_A 6
#define MAX_GRID_A 100
#define MIN_SOLAR_W 0
#define MAX_SOLAR_W 100000
#define REQ_BODY_MAX 768
static esp_err_t send_json(httpd_req_t *req, cJSON *root)
{
httpd_resp_set_type(req, "application/json");
char *json_str = cJSON_PrintUnformatted(root);
if (!json_str)
{
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"JSON encode failed");
return ESP_FAIL;
}
esp_err_t err = httpd_resp_sendstr(req, json_str);
free(json_str);
return err;
}
static esp_err_t read_body(httpd_req_t *req, char *buf, size_t buf_sz)
{
if (req->content_len <= 0)
return ESP_FAIL;
if ((size_t)req->content_len >= buf_sz)
return ESP_ERR_NO_MEM;
int remaining = req->content_len;
int off = 0;
while (remaining > 0)
{
int r = httpd_req_recv(req, buf + off, remaining);
if (r <= 0)
return ESP_FAIL;
off += r;
remaining -= r;
}
buf[off] = '\0';
return ESP_OK;
}
static const char *power_mode_to_str(loadbalancer_limit_mode_t mode)
{
switch (mode)
{
case LOADBALANCER_LIMIT_MODE_OFF:
return "off";
case LOADBALANCER_LIMIT_MODE_MANUAL:
return "manual";
case LOADBALANCER_LIMIT_MODE_GRID:
return "grid";
case LOADBALANCER_LIMIT_MODE_SOLAR:
return "solar";
case LOADBALANCER_LIMIT_MODE_GRID_SOLAR:
return "grid_solar";
default:
return "off";
}
}
static bool power_mode_from_str(const char *str, loadbalancer_limit_mode_t *out)
{
if (!str || !out)
return false;
if (strcmp(str, "off") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_OFF;
return true;
}
if (strcmp(str, "manual") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_MANUAL;
return true;
}
if (strcmp(str, "grid") == 0 ||
strcmp(str, "loadbalancer") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_GRID;
return true;
}
if (strcmp(str, "solar") == 0 ||
strcmp(str, "pv") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_SOLAR;
return true;
}
if (strcmp(str, "grid_solar") == 0 ||
strcmp(str, "solar_grid") == 0 ||
strcmp(str, "grid_pv") == 0 ||
strcmp(str, "pv_grid") == 0)
{
*out = LOADBALANCER_LIMIT_MODE_GRID_SOLAR;
return true;
}
return false;
}
static esp_err_t parse_object_if_present(cJSON *root,
const char *name,
cJSON **out)
{
cJSON *obj = cJSON_GetObjectItem(root, name);
if (!obj)
{
*out = NULL;
return ESP_OK;
}
if (!cJSON_IsObject(obj))
return ESP_ERR_INVALID_ARG;
*out = obj;
return ESP_OK;
}
static esp_err_t parse_int_field_if_present(cJSON *obj,
const char *name,
int32_t min_value,
int32_t max_value,
int32_t *out,
bool *provided)
{
if (provided)
*provided = false;
if (!obj)
return ESP_OK;
cJSON *item = cJSON_GetObjectItem(obj, name);
if (!item)
return ESP_OK;
if (!cJSON_IsNumber(item))
return ESP_ERR_INVALID_ARG;
int32_t value = (int32_t)item->valuedouble;
if (value < min_value || value > max_value)
return ESP_ERR_INVALID_ARG;
*out = value;
if (provided)
*provided = true;
return ESP_OK;
}
static esp_err_t power_config_get_handler(httpd_req_t *req)
{
cJSON *root = cJSON_CreateObject();
if (!root)
{
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
loadbalancer_limit_mode_t mode = loadbalancer_get_limit_mode();
cJSON_AddStringToObject(root, "mode", power_mode_to_str(mode));
cJSON *manual = cJSON_CreateObject();
if (!manual)
{
cJSON_Delete(root);
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
cJSON_AddItemToObject(root, "manual", manual);
cJSON_AddNumberToObject(manual,
"currentA",
(int)evse_get_charging_current());
cJSON *grid = cJSON_CreateObject();
if (!grid)
{
cJSON_Delete(root);
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
cJSON_AddItemToObject(root, "grid", grid);
cJSON_AddNumberToObject(grid,
"maxImportA",
(int)loadbalancer_grid_get_max_import_a());
cJSON *solar = cJSON_CreateObject();
if (!solar)
{
cJSON_Delete(root);
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"No mem");
return ESP_FAIL;
}
cJSON_AddItemToObject(root, "solar", solar);
cJSON_AddNumberToObject(solar,
"maxImportW",
(int)loadbalancer_pv_get_max_import_w());
esp_err_t err = send_json(req, root);
cJSON_Delete(root);
return err;
}
static esp_err_t power_config_post_handler(httpd_req_t *req)
{
if (req->content_len <= 0)
{
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Empty body");
return ESP_FAIL;
}
if (req->content_len >= REQ_BODY_MAX)
{
httpd_resp_send_err(req,
HTTPD_413_CONTENT_TOO_LARGE,
"Body too large");
return ESP_FAIL;
}
char buf[REQ_BODY_MAX];
esp_err_t rb = read_body(req, buf, sizeof(buf));
if (rb == ESP_ERR_NO_MEM)
{
httpd_resp_send_err(req,
HTTPD_413_CONTENT_TOO_LARGE,
"Body too large");
return ESP_FAIL;
}
if (rb != ESP_OK)
{
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Failed to read body");
return ESP_FAIL;
}
ESP_LOGD(TAG, "POST: %s", buf);
cJSON *json = cJSON_Parse(buf);
if (!json)
{
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Invalid JSON");
return ESP_FAIL;
}
if (!cJSON_IsObject(json))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"JSON root must be object");
return ESP_FAIL;
}
/*
* Base = current runtime/config state.
* This allows partial updates without clearing other values.
*/
loadbalancer_limit_mode_t next_mode = loadbalancer_get_limit_mode();
uint16_t next_manual_current_a = evse_get_charging_current();
uint8_t next_grid_max_a = loadbalancer_grid_get_max_import_a();
int32_t next_solar_max_w = loadbalancer_pv_get_max_import_w();
/*
* mode
*/
cJSON *mode_item = cJSON_GetObjectItem(json, "mode");
if (mode_item)
{
if (!cJSON_IsString(mode_item) || !mode_item->valuestring)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"mode must be string");
return ESP_FAIL;
}
if (!power_mode_from_str(mode_item->valuestring, &next_mode))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Invalid mode");
return ESP_FAIL;
}
}
/*
* manual
*/
cJSON *manual = NULL;
esp_err_t e = parse_object_if_present(json, "manual", &manual);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"manual must be object");
return ESP_FAIL;
}
if (manual)
{
int32_t current_a = (int32_t)next_manual_current_a;
bool provided = false;
e = parse_int_field_if_present(manual,
"currentA",
MIN_MANUAL_A,
evse_get_max_charging_current(),
&current_a,
&provided);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"manual.currentA out of range");
return ESP_FAIL;
}
if (provided)
next_manual_current_a = (uint16_t)current_a;
}
/*
* grid
*/
cJSON *grid = NULL;
e = parse_object_if_present(json, "grid", &grid);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"grid must be object");
return ESP_FAIL;
}
if (grid)
{
int32_t max_import_a = (int32_t)next_grid_max_a;
bool provided = false;
e = parse_int_field_if_present(grid,
"maxImportA",
MIN_GRID_A,
MAX_GRID_A,
&max_import_a,
&provided);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"grid.maxImportA must be 6-100");
return ESP_FAIL;
}
if (provided)
next_grid_max_a = (uint8_t)max_import_a;
}
/*
* solar
*/
cJSON *solar = NULL;
e = parse_object_if_present(json, "solar", &solar);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar must be object");
return ESP_FAIL;
}
if (solar)
{
int32_t max_import_w = next_solar_max_w;
bool provided = false;
e = parse_int_field_if_present(solar,
"maxImportW",
MIN_SOLAR_W,
MAX_SOLAR_W,
&max_import_w,
&provided);
if (e != ESP_OK)
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar.maxImportW out of range");
return ESP_FAIL;
}
if (provided)
next_solar_max_w = max_import_w;
/*
* Legacy compatibility only:
*
* Old clients may still send:
* "mode": "solar",
* "solar": { "gridCap": true }
*
* The clean API uses:
* "mode": "grid_solar"
*
* If gridCap is present, convert the mode accordingly.
*/
cJSON *legacy_grid_cap = cJSON_GetObjectItem(solar, "gridCap");
if (legacy_grid_cap)
{
if (!cJSON_IsBool(legacy_grid_cap))
{
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"solar.gridCap must be boolean");
return ESP_FAIL;
}
if (next_mode == LOADBALANCER_LIMIT_MODE_SOLAR &&
cJSON_IsTrue(legacy_grid_cap))
{
next_mode = LOADBALANCER_LIMIT_MODE_GRID_SOLAR;
}
else if (next_mode == LOADBALANCER_LIMIT_MODE_GRID_SOLAR &&
!cJSON_IsTrue(legacy_grid_cap))
{
next_mode = LOADBALANCER_LIMIT_MODE_SOLAR;
}
}
}
/*
* Normalize auxiliary flags from the selected mode.
*
* mode is the source of truth.
*/
bool apply_grid_limiter = false;
bool apply_solar_optimizer = false;
switch (next_mode)
{
case LOADBALANCER_LIMIT_MODE_OFF:
apply_grid_limiter = false;
apply_solar_optimizer = false;
break;
case LOADBALANCER_LIMIT_MODE_MANUAL:
apply_grid_limiter = false;
apply_solar_optimizer = false;
break;
case LOADBALANCER_LIMIT_MODE_GRID:
apply_grid_limiter = true;
apply_solar_optimizer = false;
break;
case LOADBALANCER_LIMIT_MODE_SOLAR:
apply_grid_limiter = false;
apply_solar_optimizer = true;
break;
case LOADBALANCER_LIMIT_MODE_GRID_SOLAR:
apply_grid_limiter = true;
apply_solar_optimizer = true;
break;
default:
cJSON_Delete(json);
httpd_resp_send_err(req,
HTTPD_400_BAD_REQUEST,
"Invalid mode");
return ESP_FAIL;
}
/*
* 1) Static/manual current.
*/
e = evse_set_charging_current(next_manual_current_a);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set manual.currentA=%u: %s",
(unsigned)next_manual_current_a,
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set manual.currentA");
return ESP_FAIL;
}
/*
* 2) Loadbalancer parameters.
*/
e = loadbalancer_grid_set_max_import_a(next_grid_max_a);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set grid.maxImportA=%u: %s",
(unsigned)next_grid_max_a,
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set grid.maxImportA");
return ESP_FAIL;
}
e = loadbalancer_pv_set_max_import_w(next_solar_max_w);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set solar.maxImportW=%ld: %s",
(long)next_solar_max_w,
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set solar.maxImportW");
return ESP_FAIL;
}
/*
* 3) Auxiliary flags derived from mode.
*/
loadbalancer_grid_set_enabled(apply_grid_limiter);
loadbalancer_pv_set_enabled(apply_solar_optimizer);
/*
* 4) Operational mode.
*/
e = loadbalancer_set_limit_mode(next_mode);
if (e != ESP_OK)
{
cJSON_Delete(json);
ESP_LOGE(TAG,
"Failed to set mode=%s: %s",
power_mode_to_str(next_mode),
esp_err_to_name(e));
httpd_resp_send_err(req,
HTTPD_500_INTERNAL_SERVER_ERROR,
"Failed to set power mode");
return ESP_FAIL;
}
ESP_LOGW(TAG,
"Power config saved: mode=%s manual=%uA gridMax=%uA solarMax=%ldW",
power_mode_to_str(next_mode),
(unsigned)next_manual_current_a,
(unsigned)next_grid_max_a,
(long)next_solar_max_w);
cJSON_Delete(json);
httpd_resp_sendstr(req, "OK");
return ESP_OK;
}
void register_power_settings_handlers(httpd_handle_t server, void *ctx)
{
httpd_uri_t get_uri = {
.uri = "/api/v1/config/power",
.method = HTTP_GET,
.handler = power_config_get_handler,
.user_ctx = ctx};
httpd_register_uri_handler(server, &get_uri);
httpd_uri_t post_uri = {
.uri = "/api/v1/config/power",
.method = HTTP_POST,
.handler = power_config_post_handler,
.user_ctx = ctx};
httpd_register_uri_handler(server, &post_uri);
}

4
components/rest_api/src/rest_main.c Executable file → Normal file
View File

@@ -1,7 +1,7 @@
#include "rest_main.h"
#include "evse_settings_api.h"
#include "meters_settings_api.h"
#include "loadbalancing_settings_api.h"
#include "power_settings_api.h"
#include "evse_link_config_api.h"
#include "network_api.h"
#include "ocpp_api.h"
@@ -59,7 +59,7 @@ esp_err_t rest_server_init(const char *base_path)
register_auth_handlers(server, ctx);
register_dashboard_handlers(server, ctx);
register_meters_settings_handlers(server, ctx);
register_loadbalancing_settings_handlers(server, ctx);
register_power_settings_handlers(server, ctx);
register_link_config_handlers(server, ctx);
register_meters_data_handlers(server, ctx);
register_scheduler_settings_handlers(server, ctx);

0
components/rest_api/src/scheduler_settings_api.c Executable file → Normal file
View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@
}
</style>
<title>ChargeFlow</title>
<script type="module" crossorigin src="/assets/index-CBPnRb7F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5y1tcufv.css">
<script type="module" crossorigin src="/assets/index-BSlegejp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-vUXH8POW.css">
</head>
<body>
<div id="root"></div>