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

View File

@ -3,7 +3,6 @@
* Copyright (C) 2022-2024 Thomas Basler and others * Copyright (C) 2022-2024 Thomas Basler and others
*/ */
#include "WebApi_ws_live.h" #include "WebApi_ws_live.h"
#include "Configuration.h"
#include "Datastore.h" #include "Datastore.h"
#include "MessageOutput.h" #include "MessageOutput.h"
#include "Utils.h" #include "Utils.h"
@ -58,43 +57,6 @@ void WebApiWsLiveClass::sendDataTaskCb()
return; 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 // Loop all inverters
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) { for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
auto inv = Hoymiles.getInverterByPos(i); auto inv = Hoymiles.getInverterByPos(i);
@ -102,29 +64,87 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
continue; continue;
} }
JsonObject invObject = invArray.createNestedObject(); const uint32_t lastUpdateInternal = inv->Statistics()->getLastUpdateFromInternal();
INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial()); if (!((lastUpdateInternal > 0 && lastUpdateInternal > _lastPublishStats[i]) || (millis() - _lastPublishStats[i] > (10 * 1000)))) {
if (inv_cfg == nullptr) {
continue; continue;
} }
invObject["serial"] = inv->serialString(); _lastPublishStats[i] = millis();
invObject["name"] = inv->name();
invObject["order"] = inv_cfg->Order; try {
invObject["data_age"] = (millis() - inv->Statistics()->getLastUpdate()) / 1000; std::lock_guard<std::mutex> lock(_mutex);
invObject["poll_enabled"] = inv->getEnablePolling(); DynamicJsonDocument root(4096);
invObject["reachable"] = inv->isReachable(); if (!Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
invObject["producing"] = inv->isProducing(); continue;
invObject["limit_relative"] = inv->SystemConfigPara()->getLimitPercent(); }
JsonVariant var = root;
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());
addTotalField(totalObj, "YieldTotal", Datastore.getTotalAcYieldTotalEnabled(), "kWh", Datastore.getTotalAcYieldTotalDigits());
JsonObject hintObj = root.createNestedObject("hints");
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()));
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) { if (inv->DevInfo()->getMaxPower() > 0) {
invObject["limit_absolute"] = inv->SystemConfigPara()->getLimitPercent() * inv->DevInfo()->getMaxPower() / 100.0; root["limit_absolute"] = inv->SystemConfigPara()->getLimitPercent() * inv->DevInfo()->getMaxPower() / 100.0;
} else { } else {
invObject["limit_absolute"] = -1; 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 // Loop all channels
for (auto& t : inv->Statistics()->getChannelTypes()) { for (auto& t : inv->Statistics()->getChannelTypes()) {
JsonObject chanTypeObj = invObject.createNestedObject(inv->Statistics()->getChannelTypeName(t)); JsonObject chanTypeObj = root.createNestedObject(inv->Statistics()->getChannelTypeName(t));
for (auto& c : inv->Statistics()->getChannelsByType(t)) { for (auto& c : inv->Statistics()->getChannelsByType(t)) {
if (t == TYPE_DC) { if (t == TYPE_DC) {
chanTypeObj[String(static_cast<uint8_t>(c))]["name"]["u"] = inv_cfg->channel[c].Name; chanTypeObj[String(static_cast<uint8_t>(c))]["name"]["u"] = inv_cfg->channel[c].Name;
@ -154,25 +174,9 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
} }
if (inv->Statistics()->hasChannelFieldValue(TYPE_INV, CH0, FLD_EVT_LOG)) { if (inv->Statistics()->hasChannelFieldValue(TYPE_INV, CH0, FLD_EVT_LOG)) {
invObject["events"] = inv->EventLog()->getEntryCount(); root["events"] = inv->EventLog()->getEntryCount();
} else { } else {
invObject["events"] = -1; root["events"] = -1;
}
}
JsonObject totalObj = root.createNestedObject("total");
addTotalField(totalObj, "Power", Datastore.getTotalAcPowerEnabled(), "W", Datastore.getTotalAcPowerDigits());
addTotalField(totalObj, "YieldDay", Datastore.getTotalAcYieldDayEnabled(), "Wh", Datastore.getTotalAcYieldDayDigits());
addTotalField(totalObj, "YieldTotal", Datastore.getTotalAcYieldTotalEnabled(), "kWh", Datastore.getTotalAcYieldTotalDigits());
JsonObject hintObj = root.createNestedObject("hints");
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;
} else {
hintObj["default_password"] = false;
} }
} }
@ -217,10 +221,38 @@ void WebApiWsLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
try { try {
std::lock_guard<std::mutex> lock(_mutex); 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(); 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(); response->setLength();
request->send(response); request->send(response);

View File

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

View File

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

View File

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

View File

@ -103,6 +103,7 @@
<div class="card-body"> <div class="card-body">
<div class="row flex-row-reverse flex-wrap-reverse g-3"> <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="chanType in [{obj: inverter.INV, name: 'INV'}, {obj: inverter.AC, name: 'AC'}, {obj: inverter.DC, name: 'DC'}].reverse()">
<template v-if="chanType.obj != null">
<template v-for="channel in Object.keys(chanType.obj).sort().reverse().map(x=>+x)" :key="channel"> <template v-for="channel in Object.keys(chanType.obj).sort().reverse().map(x=>+x)" :key="channel">
<template v-if="(chanType.name != 'DC') || <template v-if="(chanType.name != 'DC') ||
(chanType.name == 'DC' && getSumIrridiation(inverter) == 0) || (chanType.name == 'DC' && getSumIrridiation(inverter) == 0) ||
@ -116,7 +117,16 @@
</template> </template>
</template> </template>
</template> </template>
</template>
</div> </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> </div>
</div> </div>
@ -441,7 +451,16 @@ export default defineComponent({
this.socket.onmessage = (event) => { this.socket.onmessage = (event) => {
console.log(event); console.log(event);
if (event.data != "{}") { 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.dataLoading = false;
this.heartCheck(); // Reset heartbeat detection this.heartCheck(); // Reset heartbeat detection
} else { } else {