BREAKING CHANGE: Web API Endpoint /api/livedata/status

To reduce the heap usage it is necessary to send the inverters one by one instead of a huge response. A simple call to `/api/livedata/status` returns just some very general information. If detailed inverter information are required the inverter serial number has to appended `?inv=<serial number>`.
The websocket also returns only one inverter at a time. It as to be assembled at client side.
This commit is contained in:
Thomas Basler 2024-01-29 22:46:40 +01:00
parent 557c5d645e
commit c27ecc3620
6 changed files with 169 additions and 111 deletions

View File

@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include "Configuration.h"
#include <ArduinoJson.h>
#include <ESPAsyncWebServer.h>
#include <Hoymiles.h>
@ -12,16 +13,19 @@ public:
void init(AsyncWebServer& server, Scheduler& scheduler);
private:
void generateJsonResponse(JsonVariant& root);
void addField(JsonObject& root, std::shared_ptr<InverterAbstract> inv, const ChannelType_t type, const ChannelNum_t channel, const FieldId_t fieldId, String topic = "");
void addTotalField(JsonObject& root, const String& name, const float value, const String& unit, const uint8_t digits);
static void generateInverterCommonJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv);
static void generateInverterChannelJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv);
static void generateCommonJsonResponse(JsonVariant& root);
static void addField(JsonObject& root, std::shared_ptr<InverterAbstract> inv, const ChannelType_t type, const ChannelNum_t channel, const FieldId_t fieldId, String topic = "");
static void addTotalField(JsonObject& root, const String& name, const float value, const String& unit, const uint8_t digits);
void onLivedataStatus(AsyncWebServerRequest* request);
void onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len);
AsyncWebSocket _ws;
uint32_t _lastWsPublish = 0;
uint32_t _newestInverterTimestamp = 0;
uint32_t _lastPublishStats[INV_MAX_COUNT] = { 0 };
std::mutex _mutex;

View File

@ -3,7 +3,6 @@
* Copyright (C) 2022-2024 Thomas Basler and others
*/
#include "WebApi_ws_live.h"
#include "Configuration.h"
#include "Datastore.h"
#include "MessageOutput.h"
#include "Utils.h"
@ -58,43 +57,6 @@ void WebApiWsLiveClass::sendDataTaskCb()
return;
}
uint32_t maxTimeStamp = 0;
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
auto inv = Hoymiles.getInverterByPos(i);
maxTimeStamp = std::max<uint32_t>(maxTimeStamp, inv->Statistics()->getLastUpdate());
}
// Update on every inverter change or at least after 10 seconds
if (millis() - _lastWsPublish > (10 * 1000) || (maxTimeStamp != _newestInverterTimestamp)) {
try {
std::lock_guard<std::mutex> lock(_mutex);
DynamicJsonDocument root(4096 * INV_MAX_COUNT);
if (Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
JsonVariant var = root;
generateJsonResponse(var);
String buffer;
serializeJson(root, buffer);
_ws.textAll(buffer);
_newestInverterTimestamp = maxTimeStamp;
}
} catch (const std::bad_alloc& bad_alloc) {
MessageOutput.printf("Call to /api/livedata/status temporarely out of resources. Reason: \"%s\".\r\n", bad_alloc.what());
} catch (const std::exception& exc) {
MessageOutput.printf("Unknown exception in /api/livedata/status. Reason: \"%s\".\r\n", exc.what());
}
_lastWsPublish = millis();
}
}
void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
{
JsonArray invArray = root.createNestedArray("inverters");
// Loop all inverters
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
auto inv = Hoymiles.getInverterByPos(i);
@ -102,64 +64,43 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
continue;
}
JsonObject invObject = invArray.createNestedObject();
INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial());
if (inv_cfg == nullptr) {
const uint32_t lastUpdateInternal = inv->Statistics()->getLastUpdateFromInternal();
if (!((lastUpdateInternal > 0 && lastUpdateInternal > _lastPublishStats[i]) || (millis() - _lastPublishStats[i] > (10 * 1000)))) {
continue;
}
invObject["serial"] = inv->serialString();
invObject["name"] = inv->name();
invObject["order"] = inv_cfg->Order;
invObject["data_age"] = (millis() - inv->Statistics()->getLastUpdate()) / 1000;
invObject["poll_enabled"] = inv->getEnablePolling();
invObject["reachable"] = inv->isReachable();
invObject["producing"] = inv->isProducing();
invObject["limit_relative"] = inv->SystemConfigPara()->getLimitPercent();
if (inv->DevInfo()->getMaxPower() > 0) {
invObject["limit_absolute"] = inv->SystemConfigPara()->getLimitPercent() * inv->DevInfo()->getMaxPower() / 100.0;
} else {
invObject["limit_absolute"] = -1;
}
_lastPublishStats[i] = millis();
// Loop all channels
for (auto& t : inv->Statistics()->getChannelTypes()) {
JsonObject chanTypeObj = invObject.createNestedObject(inv->Statistics()->getChannelTypeName(t));
for (auto& c : inv->Statistics()->getChannelsByType(t)) {
if (t == TYPE_DC) {
chanTypeObj[String(static_cast<uint8_t>(c))]["name"]["u"] = inv_cfg->channel[c].Name;
}
addField(chanTypeObj, inv, t, c, FLD_PAC);
addField(chanTypeObj, inv, t, c, FLD_UAC);
addField(chanTypeObj, inv, t, c, FLD_IAC);
if (t == TYPE_AC) {
addField(chanTypeObj, inv, t, c, FLD_PDC, "Power DC");
} else {
addField(chanTypeObj, inv, t, c, FLD_PDC);
}
addField(chanTypeObj, inv, t, c, FLD_UDC);
addField(chanTypeObj, inv, t, c, FLD_IDC);
addField(chanTypeObj, inv, t, c, FLD_YD);
addField(chanTypeObj, inv, t, c, FLD_YT);
addField(chanTypeObj, inv, t, c, FLD_F);
addField(chanTypeObj, inv, t, c, FLD_T);
addField(chanTypeObj, inv, t, c, FLD_PF);
addField(chanTypeObj, inv, t, c, FLD_Q);
addField(chanTypeObj, inv, t, c, FLD_EFF);
if (t == TYPE_DC && inv->Statistics()->getStringMaxPower(c) > 0) {
addField(chanTypeObj, inv, t, c, FLD_IRR);
chanTypeObj[String(c)][inv->Statistics()->getChannelFieldName(t, c, FLD_IRR)]["max"] = inv->Statistics()->getStringMaxPower(c);
}
try {
std::lock_guard<std::mutex> lock(_mutex);
DynamicJsonDocument root(4096);
if (!Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
continue;
}
}
JsonVariant var = root;
if (inv->Statistics()->hasChannelFieldValue(TYPE_INV, CH0, FLD_EVT_LOG)) {
invObject["events"] = inv->EventLog()->getEntryCount();
} else {
invObject["events"] = -1;
auto invArray = var.createNestedArray("inverters");
auto invObject = invArray.createNestedObject();
generateCommonJsonResponse(var);
generateInverterCommonJsonResponse(invObject, inv);
generateInverterChannelJsonResponse(invObject, inv);
String buffer;
serializeJson(root, buffer);
_ws.textAll(buffer);
} catch (const std::bad_alloc& bad_alloc) {
MessageOutput.printf("Call to /api/livedata/status temporarely out of resources. Reason: \"%s\".\r\n", bad_alloc.what());
} catch (const std::exception& exc) {
MessageOutput.printf("Unknown exception in /api/livedata/status. Reason: \"%s\".\r\n", exc.what());
}
}
}
void WebApiWsLiveClass::generateCommonJsonResponse(JsonVariant& root)
{
JsonObject totalObj = root.createNestedObject("total");
addTotalField(totalObj, "Power", Datastore.getTotalAcPowerEnabled(), "W", Datastore.getTotalAcPowerDigits());
addTotalField(totalObj, "YieldDay", Datastore.getTotalAcYieldDayEnabled(), "Wh", Datastore.getTotalAcYieldDayDigits());
@ -169,10 +110,73 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
struct tm timeinfo;
hintObj["time_sync"] = !getLocalTime(&timeinfo, 5);
hintObj["radio_problem"] = (Hoymiles.getRadioNrf()->isInitialized() && (!Hoymiles.getRadioNrf()->isConnected() || !Hoymiles.getRadioNrf()->isPVariant())) || (Hoymiles.getRadioCmt()->isInitialized() && (!Hoymiles.getRadioCmt()->isConnected()));
if (!strcmp(Configuration.get().Security.Password, ACCESS_POINT_PASSWORD)) {
hintObj["default_password"] = true;
hintObj["default_password"] = strcmp(Configuration.get().Security.Password, ACCESS_POINT_PASSWORD) == 0;
}
void WebApiWsLiveClass::generateInverterCommonJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv)
{
const INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial());
if (inv_cfg == nullptr) {
return;
}
root["serial"] = inv->serialString();
root["name"] = inv->name();
root["order"] = inv_cfg->Order;
root["data_age"] = (millis() - inv->Statistics()->getLastUpdate()) / 1000;
root["poll_enabled"] = inv->getEnablePolling();
root["reachable"] = inv->isReachable();
root["producing"] = inv->isProducing();
root["limit_relative"] = inv->SystemConfigPara()->getLimitPercent();
if (inv->DevInfo()->getMaxPower() > 0) {
root["limit_absolute"] = inv->SystemConfigPara()->getLimitPercent() * inv->DevInfo()->getMaxPower() / 100.0;
} else {
hintObj["default_password"] = false;
root["limit_absolute"] = -1;
}
}
void WebApiWsLiveClass::generateInverterChannelJsonResponse(JsonObject& root, std::shared_ptr<InverterAbstract> inv)
{
const INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial());
if (inv_cfg == nullptr) {
return;
}
// Loop all channels
for (auto& t : inv->Statistics()->getChannelTypes()) {
JsonObject chanTypeObj = root.createNestedObject(inv->Statistics()->getChannelTypeName(t));
for (auto& c : inv->Statistics()->getChannelsByType(t)) {
if (t == TYPE_DC) {
chanTypeObj[String(static_cast<uint8_t>(c))]["name"]["u"] = inv_cfg->channel[c].Name;
}
addField(chanTypeObj, inv, t, c, FLD_PAC);
addField(chanTypeObj, inv, t, c, FLD_UAC);
addField(chanTypeObj, inv, t, c, FLD_IAC);
if (t == TYPE_AC) {
addField(chanTypeObj, inv, t, c, FLD_PDC, "Power DC");
} else {
addField(chanTypeObj, inv, t, c, FLD_PDC);
}
addField(chanTypeObj, inv, t, c, FLD_UDC);
addField(chanTypeObj, inv, t, c, FLD_IDC);
addField(chanTypeObj, inv, t, c, FLD_YD);
addField(chanTypeObj, inv, t, c, FLD_YT);
addField(chanTypeObj, inv, t, c, FLD_F);
addField(chanTypeObj, inv, t, c, FLD_T);
addField(chanTypeObj, inv, t, c, FLD_PF);
addField(chanTypeObj, inv, t, c, FLD_Q);
addField(chanTypeObj, inv, t, c, FLD_EFF);
if (t == TYPE_DC && inv->Statistics()->getStringMaxPower(c) > 0) {
addField(chanTypeObj, inv, t, c, FLD_IRR);
chanTypeObj[String(c)][inv->Statistics()->getChannelFieldName(t, c, FLD_IRR)]["max"] = inv->Statistics()->getStringMaxPower(c);
}
}
}
if (inv->Statistics()->hasChannelFieldValue(TYPE_INV, CH0, FLD_EVT_LOG)) {
root["events"] = inv->EventLog()->getEntryCount();
} else {
root["events"] = -1;
}
}
@ -217,10 +221,38 @@ void WebApiWsLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
try {
std::lock_guard<std::mutex> lock(_mutex);
AsyncJsonResponse* response = new AsyncJsonResponse(false, 4096 * INV_MAX_COUNT);
AsyncJsonResponse* response = new AsyncJsonResponse(false, 4096);
auto& root = response->getRoot();
generateJsonResponse(root);
JsonArray invArray = root.createNestedArray("inverters");
uint64_t serial = 0;
if (request->hasParam("inv")) {
String s = request->getParam("inv")->value();
serial = strtoll(s.c_str(), NULL, 16);
}
if (serial > 0) {
auto inv = Hoymiles.getInverterBySerial(serial);
if (inv != nullptr) {
JsonObject invObject = invArray.createNestedObject();
generateInverterCommonJsonResponse(invObject, inv);
generateInverterChannelJsonResponse(invObject, inv);
}
} else {
// Loop all inverters
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
auto inv = Hoymiles.getInverterByPos(i);
if (inv == nullptr) {
continue;
}
JsonObject invObject = invArray.createNestedObject();
generateInverterCommonJsonResponse(invObject, inv);
}
}
generateCommonJsonResponse(root);
response->setLength();
request->send(response);

View File

@ -136,7 +136,8 @@
"Ok": "Ok",
"Unknown": "Unbekannt",
"ShowGridProfile": "Zeige Grid Profil",
"GridProfile": "Grid Profil"
"GridProfile": "Grid Profil",
"LoadingInverter": "Warte auf Daten... (kann bis zu 10 Sekunden dauern)"
},
"eventlog": {
"Start": "Begin",

View File

@ -136,7 +136,8 @@
"Ok": "Ok",
"Unknown": "Unknown",
"ShowGridProfile": "Show Grid Profile",
"GridProfile": "Grid Profile"
"GridProfile": "Grid Profile",
"LoadingInverter": "Waiting for data... (can take up to 10 seconds)"
},
"eventlog": {
"Start": "Start",

View File

@ -136,7 +136,8 @@
"Ok": "OK",
"Unknown": "Inconnu",
"ShowGridProfile": "Show Grid Profile",
"GridProfile": "Grid Profile"
"GridProfile": "Grid Profile",
"LoadingInverter": "Waiting for data... (can take up to 10 seconds)"
},
"eventlog": {
"Start": "Départ",

View File

@ -103,20 +103,30 @@
<div class="card-body">
<div class="row flex-row-reverse flex-wrap-reverse g-3">
<template v-for="chanType in [{obj: inverter.INV, name: 'INV'}, {obj: inverter.AC, name: 'AC'}, {obj: inverter.DC, name: 'DC'}].reverse()">
<template v-for="channel in Object.keys(chanType.obj).sort().reverse().map(x=>+x)" :key="channel">
<template v-if="(chanType.name != 'DC') ||
(chanType.name == 'DC' && getSumIrridiation(inverter) == 0) ||
(chanType.name == 'DC' && getSumIrridiation(inverter) > 0 && chanType.obj[channel].Irradiation?.max || 0 > 0)
">
<div class="col">
<InverterChannelInfo :channelData="chanType.obj[channel]"
:channelType="chanType.name"
:channelNumber="channel" />
</div>
<template v-if="chanType.obj != null">
<template v-for="channel in Object.keys(chanType.obj).sort().reverse().map(x=>+x)" :key="channel">
<template v-if="(chanType.name != 'DC') ||
(chanType.name == 'DC' && getSumIrridiation(inverter) == 0) ||
(chanType.name == 'DC' && getSumIrridiation(inverter) > 0 && chanType.obj[channel].Irradiation?.max || 0 > 0)
">
<div class="col">
<InverterChannelInfo :channelData="chanType.obj[channel]"
:channelType="chanType.name"
:channelNumber="channel" />
</div>
</template>
</template>
</template>
</template>
</div>
<BootstrapAlert class="m-3" :show="!inverter.hasOwnProperty('INV')">
<div class="d-flex justify-content-center align-items-center">
<div class="spinner-border m-1" role="status">
<span class="visually-hidden">{{ $t('home.LoadingInverter') }}</span>
</div>
<span>{{ $t('home.LoadingInverter') }}</span>
</div>
</BootstrapAlert>
</div>
</div>
</div>
@ -441,7 +451,16 @@ export default defineComponent({
this.socket.onmessage = (event) => {
console.log(event);
if (event.data != "{}") {
this.liveData = JSON.parse(event.data);
const newData = JSON.parse(event.data);
Object.assign(this.liveData.total, newData.total);
Object.assign(this.liveData.hints, newData.hints);
const foundIdx = this.liveData.inverters.findIndex((element) => element.serial == newData.inverters[0].serial);
if (foundIdx == -1) {
Object.assign(this.liveData.inverters, newData.inverters);
} else {
Object.assign(this.liveData.inverters[foundIdx], newData.inverters[0]);
}
this.dataLoading = false;
this.heartCheck(); // Reset heartbeat detection
} else {