Skip to content

Commit

Permalink
BREAKING CHANGE: Web API Endpoint /api/livedata/status
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tbnobody committed Jan 30, 2024
1 parent 557c5d6 commit c27ecc3
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 109 deletions.
14 changes: 9 additions & 5 deletions include/WebApi_ws_live.h
@@ -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>
Expand All @@ -12,16 +13,19 @@ class WebApiWsLiveClass {
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;

Expand Down
212 changes: 122 additions & 90 deletions src/WebApi_ws_live.cpp
Expand Up @@ -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"
Expand Down Expand Up @@ -58,108 +57,50 @@ void WebApiWsLiveClass::sendDataTaskCb()
return;
}

uint32_t maxTimeStamp = 0;
// Loop all inverters
for (uint8_t i = 0; i < Hoymiles.getNumInverters(); i++) {
auto inv = Hoymiles.getInverterByPos(i);
maxTimeStamp = std::max<uint32_t>(maxTimeStamp, inv->Statistics()->getLastUpdate());
}
if (inv == nullptr) {
continue;
}

// Update on every inverter change or at least after 10 seconds
if (millis() - _lastWsPublish > (10 * 1000) || (maxTimeStamp != _newestInverterTimestamp)) {
const uint32_t lastUpdateInternal = inv->Statistics()->getLastUpdateFromInternal();
if (!((lastUpdateInternal > 0 && lastUpdateInternal > _lastPublishStats[i]) || (millis() - _lastPublishStats[i] > (10 * 1000)))) {
continue;
}

_lastPublishStats[i] = millis();

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);
DynamicJsonDocument root(4096);
if (!Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
continue;
}
JsonVariant var = root;

String buffer;
serializeJson(root, buffer);
auto invArray = var.createNestedArray("inverters");
auto invObject = invArray.createNestedObject();

_ws.textAll(buffer);
_newestInverterTimestamp = maxTimeStamp;
}
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());
}

_lastWsPublish = millis();
}
}

void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root)
void WebApiWsLiveClass::generateCommonJsonResponse(JsonVariant& root)
{
JsonArray invArray = root.createNestedArray("inverters");

// 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();
INVERTER_CONFIG_T* inv_cfg = Configuration.getInverterConfig(inv->serial());
if (inv_cfg == nullptr) {
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;
}

// 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);
}
}
}

if (inv->Statistics()->hasChannelFieldValue(TYPE_INV, CH0, FLD_EVT_LOG)) {
invObject["events"] = inv->EventLog()->getEntryCount();
} else {
invObject["events"] = -1;
}
}

JsonObject totalObj = root.createNestedObject("total");
addTotalField(totalObj, "Power", Datastore.getTotalAcPowerEnabled(), "W", Datastore.getTotalAcPowerDigits());
addTotalField(totalObj, "YieldDay", Datastore.getTotalAcYieldDayEnabled(), "Wh", Datastore.getTotalAcYieldDayDigits());
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/de.json
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/en.json
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/locales/fr.json
Expand Up @@ -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",
Expand Down
41 changes: 30 additions & 11 deletions webapp/src/views/HomeView.vue
Expand Up @@ -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>
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit c27ecc3

Please sign in to comment.