Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Safe Limits API (#1325) #1355

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions include/Configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ struct INVERTER_CONFIG_T {
uint8_t ReachableThreshold;
bool ZeroRuntimeDataIfUnrechable;
bool ZeroYieldDayOnMidnight;
uint16_t SafeLimitMillis;
uint16_t SafeLimitWatts;
CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT];
};

Expand Down
2 changes: 2 additions & 0 deletions include/WebApi.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include "WebApi_ntp.h"
#include "WebApi_power.h"
#include "WebApi_prometheus.h"
#include "WebApi_safe_limit.h"
#include "WebApi_security.h"
#include "WebApi_sysstatus.h"
#include "WebApi_webapp.h"
Expand Down Expand Up @@ -53,6 +54,7 @@ class WebApiClass {
WebApiNtpClass _webApiNtp;
WebApiPowerClass _webApiPower;
WebApiPrometheusClass _webApiPrometheus;
WebApiSafeLimitClass _webApiSafeLimit;
WebApiSecurityClass _webApiSecurity;
WebApiSysstatusClass _webApiSysstatus;
WebApiWebappClass _webApiWebapp;
Expand Down
26 changes: 26 additions & 0 deletions include/WebApi_safe_limit.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once

#include "Configuration.h"
#include <ESPAsyncWebServer.h>

class WebApiSafeLimitClass {
public:
void init(AsyncWebServer* server);
void loop();

private:
struct Fallback {
Fallback(): _inverterSerial(0) {}
uint64_t _inverterSerial;
int32_t _timeoutMillis;
int16_t _currentLimit;
};

void onSafeLimitPost(AsyncWebServerRequest* request);
Fallback *getFallback(uint64_t inverterSerial);

AsyncWebServer* _server;
Fallback _fallback[INV_MAX_COUNT];
};

4 changes: 4 additions & 0 deletions src/Configuration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ bool ConfigurationClass::write()
inv["reachable_threshold"] = config.Inverter[i].ReachableThreshold;
inv["zero_runtime"] = config.Inverter[i].ZeroRuntimeDataIfUnrechable;
inv["zero_day"] = config.Inverter[i].ZeroYieldDayOnMidnight;
inv["safe_limit_millis"] = config.Inverter[i].SafeLimitMillis;
inv["safe_limit_watts"] = config.Inverter[i].SafeLimitWatts;

JsonArray channel = inv.createNestedArray("channel");
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
Expand Down Expand Up @@ -264,6 +266,8 @@ bool ConfigurationClass::read()
config.Inverter[i].ReachableThreshold = inv["reachable_threshold"] | REACHABLE_THRESHOLD;
config.Inverter[i].ZeroRuntimeDataIfUnrechable = inv["zero_runtime"] | false;
config.Inverter[i].ZeroYieldDayOnMidnight = inv["zero_day"] | false;
config.Inverter[i].SafeLimitMillis = inv["safe_limit_millis"] | 0;
config.Inverter[i].SafeLimitWatts = inv["safe_limit_watts"] | 0;

JsonArray channel = inv["channel"];
for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) {
Expand Down
2 changes: 2 additions & 0 deletions src/WebApi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ void WebApiClass::init()
_webApiNtp.init(&_server);
_webApiPower.init(&_server);
_webApiPrometheus.init(&_server);
_webApiSafeLimit.init(&_server);
_webApiSecurity.init(&_server);
_webApiSysstatus.init(&_server);
_webApiWebapp.init(&_server);
Expand All @@ -57,6 +58,7 @@ void WebApiClass::loop()
_webApiNetwork.loop();
_webApiNtp.loop();
_webApiPower.loop();
_webApiSafeLimit.loop();
_webApiSecurity.loop();
_webApiSysstatus.loop();
_webApiWebapp.loop();
Expand Down
4 changes: 4 additions & 0 deletions src/WebApi_inverter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ void WebApiInverterClass::onInverterList(AsyncWebServerRequest* request)
obj["reachable_threshold"] = config.Inverter[i].ReachableThreshold;
obj["zero_runtime"] = config.Inverter[i].ZeroRuntimeDataIfUnrechable;
obj["zero_day"] = config.Inverter[i].ZeroYieldDayOnMidnight;
obj["safe_limit_millis"] = config.Inverter[i].SafeLimitMillis;
obj["safe_limit_watts"] = config.Inverter[i].SafeLimitWatts;

auto inv = Hoymiles.getInverterBySerial(config.Inverter[i].Serial);
uint8_t max_channels;
Expand Down Expand Up @@ -288,6 +290,8 @@ void WebApiInverterClass::onInverterEdit(AsyncWebServerRequest* request)
inverter.ReachableThreshold = root["reachable_threshold"] | REACHABLE_THRESHOLD;
inverter.ZeroRuntimeDataIfUnrechable = root["zero_runtime"] | false;
inverter.ZeroYieldDayOnMidnight = root["zero_day"] | false;
inverter.SafeLimitMillis = root["safe_limit_millis"] | 0;
inverter.SafeLimitWatts = root["safe_limit_watts"] | 0;

arrayCount++;
}
Expand Down
149 changes: 149 additions & 0 deletions src/WebApi_safe_limit.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: GPL-2.0-or-later
/*
* Copyright (C) 2022 Thomas Basler and others
*/
#include "WebApi_safe_limit.h"
#include "WebApi.h"
#include "WebApi_errors.h"
#include "Configuration.h"
#include "MessageOutput.h"
#include <AsyncJson.h>
#include <Hoymiles.h>
#include <Arduino.h>

static void setLimit(uint64_t inverterSerial, uint16_t watts, const char *cause)
{
auto inv = Hoymiles.getInverterBySerial(inverterSerial);
if (inv != 0) {
MessageOutput.print("Inverter ");
MessageOutput.print(inverterSerial, HEX);
MessageOutput.printf(" new limit is %uW (%s)\r\n", watts, cause);
inv->sendActivePowerControlRequest(watts, PowerLimitControlType::AbsolutNonPersistent);
} else {
MessageOutput.print("Ignored safe limit for mising inverter ");
MessageOutput.print(inverterSerial, HEX);
MessageOutput.print("\r\n");
}
}

void WebApiSafeLimitClass::init(AsyncWebServer *server)
{
for (auto &conf : Configuration.get().Inverter) {
if (conf.SafeLimitMillis != 0) {
setLimit(conf.Serial, conf.SafeLimitWatts, "init");
}
}

using std::placeholders::_1;
_server = server;
_server->on("/api/limit/safe", HTTP_POST, std::bind(&WebApiSafeLimitClass::onSafeLimitPost, this, _1));
}

void WebApiSafeLimitClass::loop()
{
int32_t millisNow = (int32_t)millis();
for (auto &fallback : _fallback) {
if (fallback._inverterSerial != 0 && millisNow - fallback._timeoutMillis > 0) {
if (auto conf = Configuration.getInverterConfig(fallback._inverterSerial)) {
setLimit(fallback._inverterSerial, conf->SafeLimitWatts, "timeout");
}
fallback._inverterSerial = 0;
}
}
}

void WebApiSafeLimitClass::onSafeLimitPost(AsyncWebServerRequest *request) {
if (!WebApi.checkCredentials(request)) {
return;
}

AsyncJsonResponse* response = new AsyncJsonResponse();
JsonObject retMsg = response->getRoot();
retMsg["type"] = "warning";

auto serialParam = request->getParam("serial", true);
auto limitParam = request->getParam("limit", true);
if (serialParam == nullptr || limitParam == nullptr) {
retMsg["message"] = "Values are missing!";
retMsg["code"] = WebApiError::GenericValueMissing;
response->setLength();
request->send(response);
return;
}

uint64_t serial = strtoll(serialParam->value().c_str(), NULL, 16);
if (serial == 0) {
retMsg["message"] = "Serial must be a number > 0!";
retMsg["code"] = WebApiError::LimitSerialZero;
response->setLength();
request->send(response);
return;
}

auto limit = limitParam->value().toInt();
if (limit == 0 || limit > 2250) {
retMsg["message"] = "Limit must between 1 and 2250!";
retMsg["code"] = WebApiError::LimitInvalidLimit;
retMsg["param"]["max"] = 2250;
response->setLength();
request->send(response);
return;
}

auto inv = Hoymiles.getInverterBySerial(serial);
if (inv == nullptr) {
retMsg["message"] = "Invalid inverter specified!";
retMsg["code"] = WebApiError::LimitInvalidInverter;
response->setLength();
request->send(response);
return;
}

auto conf = Configuration.getInverterConfig(serial);
if (conf == 0 || conf->SafeLimitMillis == 0) {
retMsg["message"] = "Unconfigured inverter specified!";
retMsg["code"] = WebApiError::LimitInvalidInverter;
response->setLength();
request->send(response);
return;
}

auto fallback = getFallback(serial);
if (fallback == 0) {
retMsg["message"] = "No empty inverter slot in safe limit state!";
retMsg["code"] = WebApiError::LimitInvalidInverter;
response->setLength();
request->send(response);
return;
}

if (fallback->_currentLimit != limit) {
setLimit(serial, limit, "update");
fallback->_currentLimit = limit;
}
fallback->_timeoutMillis = millis() + conf->SafeLimitMillis;

retMsg["type"] = "success";
retMsg["message"] = "Settings saved!";
retMsg["code"] = WebApiError::GenericSuccess;

response->setLength();
request->send(response);
}

WebApiSafeLimitClass::Fallback *WebApiSafeLimitClass::getFallback(uint64_t inverterSerial) {
for (auto &fallback : _fallback) {
if (fallback._inverterSerial == inverterSerial) {
return &fallback;
}
}
for (auto &fallback : _fallback) {
if (fallback._inverterSerial == 0) {
fallback._inverterSerial = inverterSerial;
fallback._currentLimit = 0;
fallback._timeoutMillis = 0;
return &fallback;
}
}
return 0;
}
4 changes: 4 additions & 0 deletions webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,10 @@
"ZeroRuntimeHint": "Zero runtime data (no yield data) if inverter becomes unreachable.",
"ZeroDay": "Zero daily yield at midnight",
"ZeroDayHint": "This only works if the inverter is unreachable. If data is read from the inverter, it's values will be used. (Reset only occours on power cycle)",
"SafeLimitWatts": "Safe Limit (W):",
"SafeLimitWattsHint": "Inverter will fallback automatically to this safe limit if nobody updates its non-persistent limit frequently enough.",
"SafeLimitMillis": "Safe Limit Delay (ms):",
"SafeLimitMillisHint": "Number of milliseconds to wait before falling back to the safe limit since the last non-persistent limit update; or 0 to disable it.",
"Cancel": "@:maintenancereboot.Cancel",
"Save": "@:dtuadmin.Save",
"DeleteMsg": "Are you sure you want to delete the inverter \"{name}\" with serial number {serial}?",
Expand Down
12 changes: 12 additions & 0 deletions webapp/src/views/InverterAdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@
v-model="selectedInverterData.zero_day"
type="checkbox"
:tooltip="$t('inverteradmin.ZeroDayHint')" wide/>

<InputElement :label="$t('inverteradmin.SafeLimitWatts')"
v-model="selectedInverterData.safe_limit_watts"
type="number" min="1" max="2250"
:tooltip="$t('inverteradmin.SafeLimitWattsHint')" wide/>

<InputElement :label="$t('inverteradmin.SafeLimitMillis')"
v-model="selectedInverterData.safe_limit_millis"
type="number" min="0" max="65535"
:tooltip="$t('inverteradmin.SafeLimitMillisHint')" wide/>
</div>
</div>
</form>
Expand Down Expand Up @@ -269,6 +279,8 @@ declare interface Inverter {
reachable_threshold: number;
zero_runtime: boolean;
zero_day: boolean;
safe_limit_millis: number;
safe_limit_watts: number;
channel: Array<Channel>;
}

Expand Down