diff --git a/include/Battery.h b/include/Battery.h index 39328347..ab9399e8 100644 --- a/include/Battery.h +++ b/include/Battery.h @@ -5,6 +5,8 @@ class BatteryClass { public: + uint32_t lastUpdate; + float chargeVoltage; float chargeCurrentLimitation; float dischargeCurrentLimitation; diff --git a/include/WebApi.h b/include/WebApi.h index 1449332d..e3af5cf6 100644 --- a/include/WebApi.h +++ b/include/WebApi.h @@ -27,6 +27,8 @@ #include "WebApi_vedirect.h" #include "WebApi_ws_Huawei.h" #include "WebApi_Huawei.h" +#include "WebApi_ws_Pylontech.h" +#include "WebApi_Pylontech.h" #include class WebApiClass { @@ -70,6 +72,8 @@ private: WebApiVedirectClass _webApiVedirect; WebApiHuaweiClass _webApiHuaweiClass; WebApiWsHuaweiLiveClass _webApiWsHuaweiLive; + WebApiPylontechClass _webApiPylontechClass; + WebApiWsPylontechLiveClass _webApiWsPylontechLive; }; diff --git a/include/WebApi_Pylontech.h b/include/WebApi_Pylontech.h new file mode 100644 index 00000000..26f61e26 --- /dev/null +++ b/include/WebApi_Pylontech.h @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include + +class WebApiPylontechClass { +public: + void init(AsyncWebServer* server); + void loop(); + void getJsonData(JsonObject& root); + +private: + void onStatus(AsyncWebServerRequest* request); + + AsyncWebServer* _server; +}; \ No newline at end of file diff --git a/include/WebApi_ws_Pylontech.h b/include/WebApi_ws_Pylontech.h new file mode 100644 index 00000000..9651d2fb --- /dev/null +++ b/include/WebApi_ws_Pylontech.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "ArduinoJson.h" +#include + +class WebApiWsPylontechLiveClass { +public: + WebApiWsPylontechLiveClass(); + void init(AsyncWebServer* server); + void loop(); + +private: + void generateJsonResponse(JsonVariant& root); + void onLivedataStatus(AsyncWebServerRequest* request); + void onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len); + + AsyncWebServer* _server; + AsyncWebSocket _ws; + + uint32_t _lastWsCleanup = 0; + uint32_t _lastUpdateCheck = 0; +}; \ No newline at end of file diff --git a/src/PylontechCanReceiver.cpp b/src/PylontechCanReceiver.cpp index ea013627..5d574050 100644 --- a/src/PylontechCanReceiver.cpp +++ b/src/PylontechCanReceiver.cpp @@ -133,6 +133,7 @@ void PylontechCanReceiverClass::parseCanPackets() Battery.stateOfCharge = this->readUnsignedInt16(rx_message.data); Battery.stateOfChargeLastUpdate = millis(); Battery.stateOfHealth = this->readUnsignedInt16(rx_message.data + 2); + Battery.lastUpdate = millis(); #ifdef PYLONTECH_DEBUG_ENABLED MessageOutput.printf("[Pylontech] soc: %d soh: %d\n", diff --git a/src/WebApi.cpp b/src/WebApi.cpp index 4e5fb4a2..161558e4 100644 --- a/src/WebApi.cpp +++ b/src/WebApi.cpp @@ -43,6 +43,8 @@ void WebApiClass::init() _webApiVedirect.init(&_server); _webApiWsHuaweiLive.init(&_server); _webApiHuaweiClass.init(&_server); + _webApiWsPylontechLive.init(&_server); + _webApiPylontechClass.init(&_server); _server.begin(); } @@ -74,6 +76,8 @@ void WebApiClass::loop() _webApiVedirect.loop(); _webApiWsHuaweiLive.loop(); _webApiHuaweiClass.loop(); + _webApiWsPylontechLive.loop(); + _webApiPylontechClass.loop(); } bool WebApiClass::checkCredentials(AsyncWebServerRequest* request) diff --git a/src/WebApi_Pylontech.cpp b/src/WebApi_Pylontech.cpp new file mode 100644 index 00000000..d5338e57 --- /dev/null +++ b/src/WebApi_Pylontech.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022 Thomas Basler and others + */ +#include "WebApi_Pylontech.h" +#include "Battery.h" +#include "Configuration.h" +#include "MessageOutput.h" +#include "WebApi.h" +#include "WebApi_errors.h" +#include +#include + +void WebApiPylontechClass::init(AsyncWebServer* server) +{ + using std::placeholders::_1; + + _server = server; + + _server->on("/api/battery/livedata", HTTP_GET, std::bind(&WebApiPylontechClass::onStatus, this, _1)); +} + +void WebApiPylontechClass::loop() +{ +} + +void WebApiPylontechClass::getJsonData(JsonObject& root) { + + root["data_age"] = (millis() - Battery.lastUpdate) / 1000; + + root[F("chargeVoltage")]["v"] = Battery.chargeVoltage ; + root[F("chargeVoltage")]["u"] = "V"; + root[F("chargeCurrentLimitation")]["v"] = Battery.chargeCurrentLimitation ; + root[F("chargeCurrentLimitation")]["u"] = "A"; + root[F("dischargeCurrentLimitation")]["v"] = Battery.dischargeCurrentLimitation ; + root[F("dischargeCurrentLimitation")]["u"] = "A"; + root[F("stateOfCharge")]["v"] = Battery.stateOfCharge ; + root[F("stateOfCharge")]["u"] = "%"; + root[F("stateOfHealth")]["v"] = Battery.stateOfHealth ; + root[F("stateOfHealth")]["u"] = "%"; + root[F("voltage")]["v"] = Battery.voltage; + root[F("voltage")]["u"] = "V"; + root[F("current")]["v"] = Battery.current ; + root[F("current")]["u"] = "A"; + root[F("temperature")]["v"] = Battery.temperature ; + root[F("temperature")]["u"] = "°C"; + + // Alarms + root["alarms"][F("dischargeCurrent")] = Battery.alarmOverCurrentDischarge ; + root["alarms"][F("chargeCurrent")] = Battery.alarmOverCurrentCharge ; + root["alarms"][F("lowTemperature")] = Battery.alarmUnderTemperature ; + root["alarms"][F("highTemperature")] = Battery.alarmOverTemperature ; + root["alarms"][F("lowVoltage")] = Battery.alarmUnderVoltage ; + root["alarms"][F("highVoltage")] = Battery.alarmOverVoltage ; + root["alarms"][F("bmsInternal")] = Battery.alarmBmsInternal ; + + // Warnings + root["warnings"][F("dischargeCurrent")] = Battery.warningHighCurrentDischarge ; + root["warnings"][F("chargeCurrent")] = Battery.warningHighCurrentCharge ; + root["warnings"][F("lowTemperature")] = Battery.warningLowTemperature ; + root["warnings"][F("highTemperature")] = Battery.warningHighTemperature ; + root["warnings"][F("lowVoltage")] = Battery.warningLowVoltage ; + root["warnings"][F("highVoltage")] = Battery.warningHighVoltage ; + root["warnings"][F("bmsInternal")] = Battery.warningBmsInternal ; + + // Misc + root[F("manufacturer")] = Battery.manufacturer ; + root[F("chargeEnabled")] = Battery.chargeEnabled ; + root[F("dischargeEnabled")] = Battery.dischargeEnabled ; + root[F("chargeImmediately")] = Battery.chargeImmediately ; + +} + +void WebApiPylontechClass::onStatus(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentialsReadonly(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonObject root = response->getRoot(); + getJsonData(root); + + response->setLength(); + request->send(response); +} + diff --git a/src/WebApi_ws_Pylontech.cpp b/src/WebApi_ws_Pylontech.cpp new file mode 100644 index 00000000..30c3a646 --- /dev/null +++ b/src/WebApi_ws_Pylontech.cpp @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022 Thomas Basler and others + */ +#include "WebApi_ws_Pylontech.h" +#include "AsyncJson.h" +#include "Configuration.h" +#include "Battery.h" +#include "MessageOutput.h" +#include "WebApi.h" +#include "defaults.h" + +WebApiWsPylontechLiveClass::WebApiWsPylontechLiveClass() + : _ws("/batterylivedata") +{ +} + +void WebApiWsPylontechLiveClass::init(AsyncWebServer* server) +{ + using std::placeholders::_1; + using std::placeholders::_2; + using std::placeholders::_3; + using std::placeholders::_4; + using std::placeholders::_5; + using std::placeholders::_6; + + _server = server; + _server->on("/api/batterylivedata/status", HTTP_GET, std::bind(&WebApiWsPylontechLiveClass::onLivedataStatus, this, _1)); + + _server->addHandler(&_ws); + _ws.onEvent(std::bind(&WebApiWsPylontechLiveClass::onWebsocketEvent, this, _1, _2, _3, _4, _5, _6)); +} + +void WebApiWsPylontechLiveClass::loop() +{ + // see: https://github.com/me-no-dev/ESPAsyncWebServer#limiting-the-number-of-web-socket-clients + if (millis() - _lastWsCleanup > 1000) { + _ws.cleanupClients(); + _lastWsCleanup = millis(); + } + + // do nothing if no WS client is connected + if (_ws.count() == 0) { + return; + } + + if (millis() - _lastUpdateCheck < 1000) { + return; + } + _lastUpdateCheck = millis(); + + DynamicJsonDocument root(1024); + JsonVariant var = root; + generateJsonResponse(var); + + String buffer; + if (buffer) { + serializeJson(root, buffer); + + if (Configuration.get().Security_AllowReadonly) { + _ws.setAuthentication("", ""); + } else { + _ws.setAuthentication(AUTH_USERNAME, Configuration.get().Security_Password); + } + + _ws.textAll(buffer); + } + + +} + +void WebApiWsPylontechLiveClass::generateJsonResponse(JsonVariant& root) +{ + root["data_age"] = (millis() - Battery.lastUpdate) / 1000; + + root[F("chargeVoltage")]["v"] = Battery.chargeVoltage ; + root[F("chargeVoltage")]["u"] = "V"; + root[F("chargeCurrentLimitation")]["v"] = Battery.chargeCurrentLimitation ; + root[F("chargeCurrentLimitation")]["u"] = "A"; + root[F("dischargeCurrentLimitation")]["v"] = Battery.dischargeCurrentLimitation ; + root[F("dischargeCurrentLimitation")]["u"] = "A"; + root[F("stateOfCharge")]["v"] = Battery.stateOfCharge ; + root[F("stateOfCharge")]["u"] = "%"; + root[F("stateOfHealth")]["v"] = Battery.stateOfHealth ; + root[F("stateOfHealth")]["u"] = "%"; + root[F("voltage")]["v"] = Battery.voltage; + root[F("voltage")]["u"] = "V"; + root[F("current")]["v"] = Battery.current ; + root[F("current")]["u"] = "A"; + root[F("temperature")]["v"] = Battery.temperature ; + root[F("temperature")]["u"] = "°C"; + + // Alarms + root["alarms"][F("dischargeCurrent")] = Battery.alarmOverCurrentDischarge ; + root["alarms"][F("chargeCurrent")] = Battery.alarmOverCurrentCharge ; + root["alarms"][F("lowTemperature")] = Battery.alarmUnderTemperature ; + root["alarms"][F("highTemperature")] = Battery.alarmOverTemperature ; + root["alarms"][F("lowVoltage")] = Battery.alarmUnderVoltage ; + root["alarms"][F("highVoltage")] = Battery.alarmOverVoltage ; + root["alarms"][F("bmsInternal")] = Battery.alarmBmsInternal ; + + // Warnings + root["warnings"][F("dischargeCurrent")] = Battery.warningHighCurrentDischarge ; + root["warnings"][F("chargeCurrent")] = Battery.warningHighCurrentCharge ; + root["warnings"][F("lowTemperature")] = Battery.warningLowTemperature ; + root["warnings"][F("highTemperature")] = Battery.warningHighTemperature ; + root["warnings"][F("lowVoltage")] = Battery.warningLowVoltage ; + root["warnings"][F("highVoltage")] = Battery.warningHighVoltage ; + root["warnings"][F("bmsInternal")] = Battery.warningBmsInternal ; + + // Misc + root[F("manufacturer")] = Battery.manufacturer ; + root[F("chargeEnabled")] = Battery.chargeEnabled ; + root[F("dischargeEnabled")] = Battery.dischargeEnabled ; + root[F("chargeImmediately")] = Battery.chargeImmediately ; + + +} + +void WebApiWsPylontechLiveClass::onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) +{ + if (type == WS_EVT_CONNECT) { + char str[64]; + snprintf(str, sizeof(str), "Websocket: [%s][%u] connect", server->url(), client->id()); + Serial.println(str); + MessageOutput.println(str); + } else if (type == WS_EVT_DISCONNECT) { + char str[64]; + snprintf(str, sizeof(str), "Websocket: [%s][%u] disconnect", server->url(), client->id()); + Serial.println(str); + MessageOutput.println(str); + } +} + +void WebApiWsPylontechLiveClass::onLivedataStatus(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentialsReadonly(request)) { + return; + } + AsyncJsonResponse* response = new AsyncJsonResponse(false, 1024U); + JsonVariant root = response->getRoot().as(); + generateJsonResponse(root); + + response->setLength(); + request->send(response); +} \ No newline at end of file diff --git a/src/WebApi_ws_live.cpp b/src/WebApi_ws_live.cpp index 3097fe2a..a6a952f2 100644 --- a/src/WebApi_ws_live.cpp +++ b/src/WebApi_ws_live.cpp @@ -189,6 +189,9 @@ void WebApiWsLiveClass::generateJsonResponse(JsonVariant& root) JsonObject huaweiObj = root.createNestedObject("huawei"); huaweiObj[F("enabled")] = Configuration.get().Huawei_Enabled; + JsonObject batteryObj = root.createNestedObject("battery"); + batteryObj[F("enabled")] = Configuration.get().Battery_Enabled; + } void WebApiWsLiveClass::addField(JsonObject& root, uint8_t idx, std::shared_ptr inv, ChannelType_t type, ChannelNum_t channel, FieldId_t fieldId, String topic) diff --git a/webapp/src/components/BatteryView.vue b/webapp/src/components/BatteryView.vue new file mode 100644 index 00000000..ff71b789 --- /dev/null +++ b/webapp/src/components/BatteryView.vue @@ -0,0 +1,359 @@ + + + \ No newline at end of file diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 99d81290..9377c087 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -10,6 +10,7 @@ "DTUSettings": "DTU", "DeviceManager": "Hardware", "VedirectSettings": "Ve.direct", + "PowerMeterSettings": "Power Meter", "BatterySettings": "Batterie", "AcChargerSettings": "AC Ladegerät", "ConfigManagement": "Konfigurationsverwaltung", @@ -666,5 +667,33 @@ "EnableHuawei": "Huawei R4850G2 an CAN Bus Interface aktiv", "Seconds": "@:dtuadmin.Seconds", "Save": "@:dtuadmin.Save" + }, + "battery": { + "battery": "Batterie", + "DataAge": "letzte Aktualisierung: ", + "Seconds": "vor {val} Sekunden", + "Status": "Status", + "Property": "Eigenschaft", + "Value": "Wert", + "Unit": "Einheit", + "stateOfCharge": "Ladezustand (SOC)", + "stateOfHealth": "Batteriezustand (SOH)", + "voltage": "Spannung", + "current": "Strom", + "temperature": "Temperatur", + "chargeVoltage": "Gewünschte Ladespannung (BMS)", + "chargeCurrentLimitation": "Ladestromlimit", + "dischargeCurrentLimitation": "Entladestromlimit", + "warn_alarm": "Warnungen und Alarme", + "ok": "OK", + "alarm": "Alarm", + "warning": "Warnung", + "dischargeCurrent": "Entladestrom", + "chargeCurrent": "Ladestrom", + "lowTemperature": "Temperatur niedrig", + "highTemperature": "Temperatur hoch", + "lowVoltage": "Spannung niedrig", + "highVoltage": "Spannung hoch", + "bmsInternal": "BMS intern" } } diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index fba77ae5..4f3edef9 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -671,5 +671,33 @@ "EnableHuawei": "Enable Huawei R4850G2 on CAN Bus Interface", "Seconds": "@:dtuadmin.Seconds", "Save": "@:dtuadmin.Save" + }, + "battery": { + "battery": "battery", + "DataAge": "Data Age: ", + "Seconds": " {val} seconds", + "Status": "Status", + "Property": "Property", + "Value": "Value", + "Unit": "Unit", + "stateOfCharge": "State of charge", + "stateOfHealth": "State of health", + "voltage": "Voltage", + "current": "Current", + "temperature": "Temperature", + "chargeVoltage": "Requested charge voltage", + "chargeCurrentLimitation": "Charge current limit", + "dischargeCurrentLimitation": "Discharge current limit", + "warn_alarm": "Alarms and warnings", + "ok": "OK", + "alarm": "Alarm", + "warning": "Warning", + "dischargeCurrent": "Discharge current", + "chargeCurrent": "Charge current", + "lowTemperature": "Low temperature", + "highTemperature": "High temperature", + "lowVoltage": "Low voltage", + "highVoltage": "High voltage", + "bmsInternal": "BMS internal" } } \ No newline at end of file diff --git a/webapp/src/locales/fr.json b/webapp/src/locales/fr.json index 89b10d4b..b69ab2cc 100644 --- a/webapp/src/locales/fr.json +++ b/webapp/src/locales/fr.json @@ -10,6 +10,7 @@ "DTUSettings": "DTU", "DeviceManager": "Périphériques", "VedirectSettings": "Ve.direct", + "PowerMeterSettings": "Power Meter", "BatterySettings": "Battery", "AcChargerSettings": "AC Charger", "ConfigManagement": "Gestion de la configuration", @@ -608,5 +609,33 @@ "EnableHuawei": "Enable Huawei R4850G2 on CAN Bus Interface", "Seconds": "@:dtuadmin.Seconds", "Save": "@:dtuadmin.Save" + }, + "battery": { + "battery": "battery", + "DataAge": "Data Age: ", + "Seconds": " {val} seconds", + "Status": "Status", + "Property": "Property", + "Value": "Value", + "Unit": "Unit", + "stateOfCharge": "State of charge", + "stateOfHealth": "State of health", + "voltage": "Voltage", + "current": "Current", + "temperature": "Temperature", + "chargeVoltage": "Requested charge voltage", + "chargeCurrentLimitation": "Charge current limit", + "dischargeCurrentLimitation": "Discharge current limit", + "warn_alarm": "Alarms and warnings", + "ok": "OK", + "alarm": "Alarm", + "warning": "Warning", + "dischargeCurrent": "Discharge current", + "chargeCurrent": "Charge current", + "lowTemperature": "Low temperature", + "highTemperature": "High temperature", + "lowVoltage": "Low voltage", + "highVoltage": "High voltage", + "bmsInternal": "BMS internal" } } \ No newline at end of file diff --git a/webapp/src/types/BatteryDataStatus.ts b/webapp/src/types/BatteryDataStatus.ts new file mode 100644 index 00000000..7427cfb8 --- /dev/null +++ b/webapp/src/types/BatteryDataStatus.ts @@ -0,0 +1,32 @@ +import type { ValueObject } from '@/types/LiveDataStatus'; + +interface BatteryFlags { + dischargeCurrent: boolean; + chargeCurrent: boolean; + lowTemperature: boolean; + highTemperature: boolean; + lowVoltage: boolean; + highVoltage: boolean; + bmsInternal: boolean; +} + + +// Battery +export interface Battery { + data_age: 0; + chargeVoltage: ValueObject; + chargeCurrentLimitation: ValueObject; + dischargeCurrentLimitation: ValueObject; + stateOfCharge: ValueObject; + stateOfChargeLastUpdate: ValueObject; + stateOfHealth: ValueObject; + voltage: ValueObject; + current: ValueObject; + temperature: ValueObject; + warnings: BatteryFlags; + alarms: BatteryFlags; + manufacturer: string; + chargeEnabled: boolean; + dischargeEnabled: boolean; + chargeImmediately: boolean; +} \ No newline at end of file diff --git a/webapp/src/types/LiveDataStatus.ts b/webapp/src/types/LiveDataStatus.ts index e7ab5b7e..9501b34b 100644 --- a/webapp/src/types/LiveDataStatus.ts +++ b/webapp/src/types/LiveDataStatus.ts @@ -54,10 +54,15 @@ export interface Huawei { enabled: boolean; } +export interface Battery { + enabled: boolean; +} + export interface LiveData { inverters: Inverter[]; total: Total; hints: Hints; vedirect: Vedirect; huawei: Huawei; + battery: Battery; } diff --git a/webapp/src/views/HomeView.vue b/webapp/src/views/HomeView.vue index 7bb0cb3a..28546f0b 100644 --- a/webapp/src/views/HomeView.vue +++ b/webapp/src/views/HomeView.vue @@ -114,6 +114,9 @@ +
+ +
@@ -329,6 +332,7 @@ import InverterChannelInfo from "@/components/InverterChannelInfo.vue"; import InverterTotalInfo from '@/components/InverterTotalInfo.vue'; import VedirectView from '@/components/VedirectView.vue'; import HuaweiView from '@/components/HuaweiView.vue' +import BatteryView from '@/components/BatteryView.vue' import type { DevInfoStatus } from '@/types/DevInfoStatus'; import type { EventlogItems } from '@/types/EventlogStatus'; import type { LimitConfig } from '@/types/LimitConfig'; @@ -370,7 +374,8 @@ export default defineComponent({ BIconToggleOn, BIconXCircleFill, VedirectView, - HuaweiView + HuaweiView, + BatteryView }, data() { return { diff --git a/webapp_dist/index.html.gz b/webapp_dist/index.html.gz index f7547917..9996657e 100644 Binary files a/webapp_dist/index.html.gz and b/webapp_dist/index.html.gz differ diff --git a/webapp_dist/js/app.js.gz b/webapp_dist/js/app.js.gz index 83efea62..0afa6829 100644 Binary files a/webapp_dist/js/app.js.gz and b/webapp_dist/js/app.js.gz differ diff --git a/webapp_dist/zones.json.gz b/webapp_dist/zones.json.gz index 45374b96..14578ad0 100644 Binary files a/webapp_dist/zones.json.gz and b/webapp_dist/zones.json.gz differ