DPL settings in web app: split metadata from config

users are manipulating the DPL using HTTP POST requests. often they are
requesting the current settings using HTTP GET on the respective route,
then change a particular settings, and send all the data back using HTTP
POST. if they failed to remove the metadata node from the JSON,
OpenDTU-OnBattery would not be able to process the JSON due to its size.
the web app does not submit the metadata.

to avoid problems, the metadata is now split from the configuration
data.
This commit is contained in:
Bernhard Kirchen 2024-03-23 21:03:56 +01:00
parent 8bfb5c6523
commit 054a677575
4 changed files with 56 additions and 36 deletions

View File

@ -11,6 +11,7 @@ public:
private: private:
void onStatus(AsyncWebServerRequest* request); void onStatus(AsyncWebServerRequest* request);
void onMetaData(AsyncWebServerRequest* request);
void onAdminGet(AsyncWebServerRequest* request); void onAdminGet(AsyncWebServerRequest* request);
void onAdminPost(AsyncWebServerRequest* request); void onAdminPost(AsyncWebServerRequest* request);

View File

@ -22,18 +22,14 @@ void WebApiPowerLimiterClass::init(AsyncWebServer& server, Scheduler& scheduler)
_server->on("/api/powerlimiter/status", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onStatus, this, _1)); _server->on("/api/powerlimiter/status", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onStatus, this, _1));
_server->on("/api/powerlimiter/config", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onAdminGet, this, _1)); _server->on("/api/powerlimiter/config", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onAdminGet, this, _1));
_server->on("/api/powerlimiter/config", HTTP_POST, std::bind(&WebApiPowerLimiterClass::onAdminPost, this, _1)); _server->on("/api/powerlimiter/config", HTTP_POST, std::bind(&WebApiPowerLimiterClass::onAdminPost, this, _1));
_server->on("/api/powerlimiter/metadata", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onMetaData, this, _1));
} }
void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request) void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request)
{ {
auto const& config = Configuration.get(); auto const& config = Configuration.get();
size_t invAmount = 0; AsyncJsonResponse* response = new AsyncJsonResponse(false, 512);
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
if (config.Inverter[i].Serial != 0) { ++invAmount; }
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, 1024 + 384 * invAmount);
auto& root = response->getRoot(); auto& root = response->getRoot();
root["enabled"] = config.PowerLimiter.Enabled; root["enabled"] = config.PowerLimiter.Enabled;
@ -60,12 +56,29 @@ void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request)
root["full_solar_passthrough_start_voltage"] = static_cast<int>(config.PowerLimiter.FullSolarPassThroughStartVoltage * 100 + 0.5) / 100.0; root["full_solar_passthrough_start_voltage"] = static_cast<int>(config.PowerLimiter.FullSolarPassThroughStartVoltage * 100 + 0.5) / 100.0;
root["full_solar_passthrough_stop_voltage"] = static_cast<int>(config.PowerLimiter.FullSolarPassThroughStopVoltage * 100 + 0.5) / 100.0; root["full_solar_passthrough_stop_voltage"] = static_cast<int>(config.PowerLimiter.FullSolarPassThroughStopVoltage * 100 + 0.5) / 100.0;
JsonObject metadata = root.createNestedObject("metadata"); response->setLength();
metadata["power_meter_enabled"] = config.PowerMeter.Enabled; request->send(response);
metadata["battery_enabled"] = config.Battery.Enabled; }
metadata["charge_controller_enabled"] = config.Vedirect.Enabled;
JsonObject inverters = metadata.createNestedObject("inverters"); void WebApiPowerLimiterClass::onMetaData(AsyncWebServerRequest* request)
{
if (!WebApi.checkCredentials(request)) { return; }
auto const& config = Configuration.get();
size_t invAmount = 0;
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
if (config.Inverter[i].Serial != 0) { ++invAmount; }
}
AsyncJsonResponse* response = new AsyncJsonResponse(false, 256 + 256 * invAmount);
auto& root = response->getRoot();
root["power_meter_enabled"] = config.PowerMeter.Enabled;
root["battery_enabled"] = config.Battery.Enabled;
root["charge_controller_enabled"] = config.Vedirect.Enabled;
JsonObject inverters = root.createNestedObject("inverters");
for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { for (uint8_t i = 0; i < INV_MAX_COUNT; i++) {
if (config.Inverter[i].Serial == 0) { continue; } if (config.Inverter[i].Serial == 0) { continue; }

View File

@ -42,5 +42,4 @@ export interface PowerLimiterConfig {
full_solar_passthrough_soc: number; full_solar_passthrough_soc: number;
full_solar_passthrough_start_voltage: number; full_solar_passthrough_start_voltage: number;
full_solar_passthrough_stop_voltage: number; full_solar_passthrough_stop_voltage: number;
metadata: PowerLimiterMetaData;
} }

View File

@ -57,7 +57,7 @@
<div class="col-sm-8"> <div class="col-sm-8">
<select id="inverter_serial" class="form-select" v-model="powerLimiterConfigList.inverter_serial" required> <select id="inverter_serial" class="form-select" v-model="powerLimiterConfigList.inverter_serial" required>
<option value="" disabled hidden selected>{{ $t('powerlimiteradmin.SelectInverter') }}</option> <option value="" disabled hidden selected>{{ $t('powerlimiteradmin.SelectInverter') }}</option>
<option v-for="(inv, serial) in powerLimiterConfigList.metadata.inverters" :key="serial" :value="serial"> <option v-for="(inv, serial) in powerLimiterMetaData.inverters" :key="serial" :value="serial">
{{ inv.name }} ({{ inv.type }}) {{ inv.name }} ({{ inv.type }})
</option> </option>
</select> </select>
@ -74,7 +74,7 @@
</label> </label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="inverter_channel" class="form-select" v-model="powerLimiterConfigList.inverter_channel_id"> <select id="inverter_channel" class="form-select" v-model="powerLimiterConfigList.inverter_channel_id">
<option v-for="channel in range(powerLimiterConfigList.metadata.inverters[powerLimiterConfigList.inverter_serial].channels)" :key="channel" :value="channel"> <option v-for="channel in range(powerLimiterMetaData.inverters[powerLimiterConfigList.inverter_serial].channels)" :key="channel" :value="channel">
{{ channel + 1 }} {{ channel + 1 }}
</option> </option>
</select> </select>
@ -194,7 +194,7 @@
<div class="alert alert-secondary" role="alert" v-html="$t('powerlimiteradmin.VoltageLoadCorrectionInfo')"></div> <div class="alert alert-secondary" role="alert" v-html="$t('powerlimiteradmin.VoltageLoadCorrectionInfo')"></div>
</CardElement> </CardElement>
<FormFooter @reload="getPowerLimiterConfig"/> <FormFooter @reload="getAllData"/>
</form> </form>
</BasePage> </BasePage>
</template> </template>
@ -208,7 +208,7 @@ import CardElement from '@/components/CardElement.vue';
import FormFooter from '@/components/FormFooter.vue'; import FormFooter from '@/components/FormFooter.vue';
import InputElement from '@/components/InputElement.vue'; import InputElement from '@/components/InputElement.vue';
import { BIconInfoCircle } from 'bootstrap-icons-vue'; import { BIconInfoCircle } from 'bootstrap-icons-vue';
import type { PowerLimiterConfig } from "@/types/PowerLimiterConfig"; import type { PowerLimiterConfig, PowerLimiterMetaData } from "@/types/PowerLimiterConfig";
export default defineComponent({ export default defineComponent({
components: { components: {
@ -223,6 +223,7 @@ export default defineComponent({
return { return {
dataLoading: true, dataLoading: true,
powerLimiterConfigList: {} as PowerLimiterConfig, powerLimiterConfigList: {} as PowerLimiterConfig,
powerLimiterMetaData: {} as PowerLimiterMetaData,
alertMessage: "", alertMessage: "",
alertType: "info", alertType: "info",
showAlert: false, showAlert: false,
@ -230,17 +231,18 @@ export default defineComponent({
}; };
}, },
created() { created() {
this.getPowerLimiterConfig(); this.getAllData();
}, },
watch: { watch: {
'powerLimiterConfigList.inverter_serial'(newVal) { 'powerLimiterConfigList.inverter_serial'(newVal) {
var cfg = this.powerLimiterConfigList; var cfg = this.powerLimiterConfigList;
var meta = this.powerLimiterMetaData;
if (newVal === "") { return; } // do not try to convert the placeholder value if (newVal === "") { return; } // do not try to convert the placeholder value
if (cfg.metadata.inverters[newVal] !== undefined) { return; } if (meta.inverters[newVal] !== undefined) { return; }
for (const [serial, inverter] of Object.entries(cfg.metadata.inverters)) { for (const [serial, inverter] of Object.entries(meta.inverters)) {
// cfg.inverter_serial might be too large to parse as a 32 bit // cfg.inverter_serial might be too large to parse as a 32 bit
// int, so we make sure to only try to parse two characters. if // int, so we make sure to only try to parse two characters. if
// cfg.inverter_serial is indeed an old position based index, // cfg.inverter_serial is indeed an old position based index,
@ -261,30 +263,31 @@ export default defineComponent({
methods: { methods: {
getConfigHints() { getConfigHints() {
var cfg = this.powerLimiterConfigList; var cfg = this.powerLimiterConfigList;
var meta = this.powerLimiterMetaData;
var hints = []; var hints = [];
if (cfg.metadata.power_meter_enabled !== true) { if (meta.power_meter_enabled !== true) {
hints.push({severity: "requirement", subject: "PowerMeterDisabled"}); hints.push({severity: "requirement", subject: "PowerMeterDisabled"});
this.configAlert = true; this.configAlert = true;
} }
if (typeof cfg.metadata.inverters === "undefined" || Object.keys(cfg.metadata.inverters).length == 0) { if (typeof meta.inverters === "undefined" || Object.keys(meta.inverters).length == 0) {
hints.push({severity: "requirement", subject: "NoInverter"}); hints.push({severity: "requirement", subject: "NoInverter"});
this.configAlert = true; this.configAlert = true;
} }
else { else {
var inv = cfg.metadata.inverters[cfg.inverter_serial]; var inv = meta.inverters[cfg.inverter_serial];
if (inv !== undefined && !(inv.poll_enable && inv.command_enable && inv.poll_enable_night && inv.command_enable_night)) { if (inv !== undefined && !(inv.poll_enable && inv.command_enable && inv.poll_enable_night && inv.command_enable_night)) {
hints.push({severity: "requirement", subject: "InverterCommunication"}); hints.push({severity: "requirement", subject: "InverterCommunication"});
} }
} }
if (!cfg.is_inverter_solar_powered) { if (!cfg.is_inverter_solar_powered) {
if (!cfg.metadata.charge_controller_enabled) { if (!meta.charge_controller_enabled) {
hints.push({severity: "optional", subject: "NoChargeController"}); hints.push({severity: "optional", subject: "NoChargeController"});
} }
if (!cfg.metadata.battery_enabled) { if (!meta.battery_enabled) {
hints.push({severity: "optional", subject: "NoBatteryInterface"}); hints.push({severity: "optional", subject: "NoBatteryInterface"});
} }
} }
@ -296,13 +299,15 @@ export default defineComponent({
}, },
canUseSolarPassthrough() { canUseSolarPassthrough() {
var cfg = this.powerLimiterConfigList; var cfg = this.powerLimiterConfigList;
var canUse = this.isEnabled() && cfg.metadata.charge_controller_enabled && !cfg.is_inverter_solar_powered; var meta = this.powerLimiterMetaData;
var canUse = this.isEnabled() && meta.charge_controller_enabled && !cfg.is_inverter_solar_powered;
if (!canUse) { cfg.solar_passthrough_enabled = false; } if (!canUse) { cfg.solar_passthrough_enabled = false; }
return canUse; return canUse;
}, },
canUseSoCThresholds() { canUseSoCThresholds() {
var cfg = this.powerLimiterConfigList; var cfg = this.powerLimiterConfigList;
return this.isEnabled() && cfg.metadata.battery_enabled && !cfg.is_inverter_solar_powered; var meta = this.powerLimiterMetaData;
return this.isEnabled() && meta.battery_enabled && !cfg.is_inverter_solar_powered;
}, },
canUseVoltageThresholds() { canUseVoltageThresholds() {
var cfg = this.powerLimiterConfigList; var cfg = this.powerLimiterConfigList;
@ -316,6 +321,7 @@ export default defineComponent({
}, },
needsChannelSelection() { needsChannelSelection() {
var cfg = this.powerLimiterConfigList; var cfg = this.powerLimiterConfigList;
var meta = this.powerLimiterMetaData;
var reset = function() { var reset = function() {
cfg.inverter_channel_id = 0; cfg.inverter_channel_id = 0;
@ -326,7 +332,7 @@ export default defineComponent({
if (cfg.is_inverter_solar_powered) { return reset(); } if (cfg.is_inverter_solar_powered) { return reset(); }
var inverter = cfg.metadata.inverters[cfg.inverter_serial]; var inverter = meta.inverters[cfg.inverter_serial];
if (inverter === undefined) { return reset(); } if (inverter === undefined) { return reset(); }
if (cfg.inverter_channel_id >= inverter.channels) { if (cfg.inverter_channel_id >= inverter.channels) {
@ -335,24 +341,25 @@ export default defineComponent({
return inverter.channels > 1; return inverter.channels > 1;
}, },
getPowerLimiterConfig() { getAllData() {
this.dataLoading = true; this.dataLoading = true;
fetch("/api/powerlimiter/metadata", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => {
this.powerLimiterMetaData = data;
fetch("/api/powerlimiter/config", { headers: authHeader() }) fetch("/api/powerlimiter/config", { headers: authHeader() })
.then((response) => handleResponse(response, this.$emitter, this.$router)) .then((response) => handleResponse(response, this.$emitter, this.$router))
.then((data) => { .then((data) => {
this.powerLimiterConfigList = data; this.powerLimiterConfigList = data;
this.dataLoading = false; this.dataLoading = false;
}); });
});
}, },
savePowerLimiterConfig(e: Event) { savePowerLimiterConfig(e: Event) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(); const formData = new FormData();
formData.append("data", JSON.stringify(this.powerLimiterConfigList, (key, value) => { formData.append("data", JSON.stringify(this.powerLimiterConfigList));
// do not submit metadata
if (key === "metadata") { return undefined; }
return value;
}));
fetch("/api/powerlimiter/config", { fetch("/api/powerlimiter/config", {
method: "POST", method: "POST",