polish support for second VE.Direct MPPT charge controller

* fix compiler warning in SerialPortManager.cpp: function must not
  return void

* clean up and simplify implementation of usesHwPort2()
  * make const
  * overrides are final
  * default implementation returns false
  * implement in header, as the implementation is very simple

* rename PortManager to SerialPortManager. as "PortManager" is too
  generic, the static instance of the serial port manager is renamed to
  "SerialPortManager". the class is therefore renamed to
  SerialPortManagerClass, which is in line with other (static) classes
  withing OpenDTU(-OnBattery).

* implement separate data ages for MPPT charge controllers

* make sure MPPT data and live data time out

* do not use invalid data of MPPT controlers for calculations

* add :key binding to v-for iterating over MPPT instances
This commit is contained in:
Bernhard Kirchen 2024-03-16 22:22:39 +01:00 committed by Bernhard Kirchen
parent 75541be248
commit 7d6b7252bf
19 changed files with 103 additions and 87 deletions

View File

@ -14,7 +14,7 @@ public:
virtual void deinit() = 0;
virtual void loop() = 0;
virtual std::shared_ptr<BatteryStats> getStats() const = 0;
virtual bool usesHwPort2() = 0;
virtual bool usesHwPort2() const { return false; }
};
class BatteryClass {

View File

@ -19,7 +19,7 @@ class Controller : public BatteryProvider {
void deinit() final;
void loop() final;
std::shared_ptr<BatteryStats> getStats() const final { return _stats; }
bool usesHwPort2() override;
bool usesHwPort2() const final { return true; }
private:
enum class Status : unsigned {

View File

@ -12,7 +12,6 @@ public:
void deinit() final;
void loop() final { return; } // this class is event-driven
std::shared_ptr<BatteryStats> getStats() const final { return _stats; }
bool usesHwPort2() override;
private:
bool _verboseLogging = false;

View File

@ -14,7 +14,6 @@ public:
void deinit() final;
void loop() final;
std::shared_ptr<BatteryStats> getStats() const final { return _stats; }
bool usesHwPort2() override;
private:
uint16_t readUnsignedInt16(uint8_t *data);

View File

@ -3,7 +3,7 @@
#include <map>
class SerialPortManager {
class SerialPortManagerClass {
public:
bool allocateMpptPort(int port);
bool allocateBatteryPort(int port);
@ -24,4 +24,4 @@ private:
static const char* print(Owner owner);
};
extern SerialPortManager PortManager;
extern SerialPortManagerClass SerialPortManager;

View File

@ -22,6 +22,7 @@ public:
// returns the data age of all controllers,
// i.e, the youngest data's age is returned.
uint32_t getDataAgeMillis() const;
uint32_t getDataAgeMillis(size_t idx) const;
std::optional<VeDirectMpptController::spData_t> getData(size_t idx = 0) const;

View File

@ -9,7 +9,7 @@ public:
void deinit() final { }
void loop() final;
std::shared_ptr<BatteryStats> getStats() const final { return _stats; }
bool usesHwPort2() override;
bool usesHwPort2() const final { return true; }
private:
uint32_t _lastUpdate = 0;

View File

@ -14,7 +14,7 @@ public:
void init(AsyncWebServer& server, Scheduler& scheduler);
private:
void generateJsonResponse(JsonVariant& root);
void generateJsonResponse(JsonVariant& root, bool fullUpdate);
static void populateJson(const JsonObject &root, const VeDirectMpptController::spData_t &spMpptData);
void onLivedataStatus(AsyncWebServerRequest* request);
void onWebsocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len);
@ -22,8 +22,8 @@ private:
AsyncWebServer* _server;
AsyncWebSocket _ws;
uint32_t _lastWsPublish = 0;
uint32_t _dataAgeMillis = 0;
uint32_t _lastFullPublish = 0;
uint32_t _dataAgeMillis[VICTRON_MAX_COUNT] = { 0 };
static constexpr uint16_t _responseSize = VICTRON_MAX_COUNT * (1024 + 128);
std::mutex _mutex;

View File

@ -281,13 +281,7 @@ int VeDirectFrameHandler::hexRxEvent(uint8_t inbyte) {
}
bool VeDirectFrameHandler::isDataValid(veStruct const& frame) const {
if (_lastUpdate == 0) {
return false;
}
if (strlen(frame.SER) == 0) {
return false;
}
return true;
return strlen(frame.SER) > 0 && _lastUpdate > 0 && (millis() - _lastUpdate) < (10 * 1000);
}
uint32_t VeDirectFrameHandler::getLastUpdate() const

View File

@ -39,7 +39,7 @@ void BatteryClass::updateSettings()
_upProvider->deinit();
_upProvider = nullptr;
}
PortManager.invalidateBatteryPort();
SerialPortManager.invalidateBatteryPort();
CONFIG_T& config = Configuration.get();
if (!config.Battery.Enabled) { return; }
@ -65,7 +65,7 @@ void BatteryClass::updateSettings()
}
if(_upProvider->usesHwPort2()) {
if (!PortManager.allocateBatteryPort(2)) {
if (!SerialPortManager.allocateBatteryPort(2)) {
MessageOutput.printf("[Battery] Serial port %d already in use. Initialization aborted!\r\n", 2);
_upProvider = nullptr;
return;
@ -73,7 +73,7 @@ void BatteryClass::updateSettings()
}
if (!_upProvider->init(verboseLogging)) {
PortManager.invalidateBatteryPort();
SerialPortManager.invalidateBatteryPort();
_upProvider = nullptr;
}
}

View File

@ -427,8 +427,4 @@ void Controller::processDataPoints(DataPointContainer const& dataPoints)
}
}
bool Controller::usesHwPort2() {
return true;
}
} /* namespace JkBms */

View File

@ -112,7 +112,3 @@ void MqttBattery::onMqttMessageVoltage(espMqttClientTypes::MessageProperties con
*voltage, topic);
}
}
bool MqttBattery::usesHwPort2() {
return false;
}

View File

@ -266,10 +266,6 @@ bool PylontechCanReceiver::getBit(uint8_t value, uint8_t bit)
return (value & (1 << bit)) >> bit;
}
bool PylontechCanReceiver::usesHwPort2() {
return false;
}
#ifdef PYLONTECH_DUMMY
void PylontechCanReceiver::dummyData()
{

View File

@ -4,19 +4,19 @@
#define MAX_CONTROLLERS 3
SerialPortManager PortManager;
SerialPortManagerClass SerialPortManager;
bool SerialPortManager::allocateBatteryPort(int port)
bool SerialPortManagerClass::allocateBatteryPort(int port)
{
return allocatePort(port, Owner::BATTERY);
}
bool SerialPortManager::allocateMpptPort(int port)
bool SerialPortManagerClass::allocateMpptPort(int port)
{
return allocatePort(port, Owner::MPPT);
}
bool SerialPortManager::allocatePort(uint8_t port, Owner owner)
bool SerialPortManagerClass::allocatePort(uint8_t port, Owner owner)
{
if (port >= MAX_CONTROLLERS) {
MessageOutput.printf("[SerialPortManager] Invalid serial port = %d \r\n", port);
@ -26,17 +26,17 @@ bool SerialPortManager::allocatePort(uint8_t port, Owner owner)
return allocatedPorts.insert({port, owner}).second;
}
void SerialPortManager::invalidateBatteryPort()
void SerialPortManagerClass::invalidateBatteryPort()
{
invalidate(Owner::BATTERY);
}
void SerialPortManager::invalidateMpptPorts()
void SerialPortManagerClass::invalidateMpptPorts()
{
invalidate(Owner::MPPT);
}
void SerialPortManager::invalidate(Owner owner)
void SerialPortManagerClass::invalidate(Owner owner)
{
for (auto it = allocatedPorts.begin(); it != allocatedPorts.end();) {
if (it->second == owner) {
@ -48,7 +48,7 @@ void SerialPortManager::invalidate(Owner owner)
}
}
const char* SerialPortManager::print(Owner owner)
const char* SerialPortManagerClass::print(Owner owner)
{
switch (owner) {
case BATTERY:
@ -56,4 +56,5 @@ const char* SerialPortManager::print(Owner owner)
case MPPT:
return "MPPT";
}
return "unknown";
}

View File

@ -22,7 +22,7 @@ void VictronMpptClass::updateSettings()
std::lock_guard<std::mutex> lock(_mutex);
_controllers.clear();
PortManager.invalidateMpptPorts();
SerialPortManager.invalidateMpptPorts();
CONFIG_T& config = Configuration.get();
if (!config.Vedirect.Enabled) { return; }
@ -47,7 +47,7 @@ bool VictronMpptClass::initController(int8_t rx, int8_t tx, bool logging, int hw
return false;
}
if (!PortManager.allocateMpptPort(hwSerialPort)) {
if (!SerialPortManager.allocateMpptPort(hwSerialPort)) {
MessageOutput.printf("[VictronMppt] Serial port %d already in use. Initialization aborted!\r\n",
hwSerialPort);
return false;
@ -110,6 +110,15 @@ uint32_t VictronMpptClass::getDataAgeMillis() const
return age;
}
uint32_t VictronMpptClass::getDataAgeMillis(size_t idx) const
{
std::lock_guard<std::mutex> lock(_mutex);
if (_controllers.empty() || idx >= _controllers.size()) { return 0; }
return millis() - _controllers[idx]->getLastUpdate();
}
std::optional<VeDirectMpptController::spData_t> VictronMpptClass::getData(size_t idx) const
{
std::lock_guard<std::mutex> lock(_mutex);
@ -128,6 +137,7 @@ int32_t VictronMpptClass::getPowerOutputWatts() const
int32_t sum = 0;
for (const auto& upController : _controllers) {
if (!upController->isDataValid()) { continue; }
sum += upController->getData()->P;
}
@ -139,6 +149,7 @@ int32_t VictronMpptClass::getPanelPowerWatts() const
int32_t sum = 0;
for (const auto& upController : _controllers) {
if (!upController->isDataValid()) { continue; }
sum += upController->getData()->PPV;
}
@ -150,6 +161,7 @@ double VictronMpptClass::getYieldTotal() const
double sum = 0;
for (const auto& upController : _controllers) {
if (!upController->isDataValid()) { continue; }
sum += upController->getData()->H19;
}
@ -161,6 +173,7 @@ double VictronMpptClass::getYieldDay() const
double sum = 0;
for (const auto& upController : _controllers) {
if (!upController->isDataValid()) { continue; }
sum += upController->getData()->H20;
}
@ -172,6 +185,7 @@ double VictronMpptClass::getOutputVoltage() const
double min = -1;
for (const auto& upController : _controllers) {
if (!upController->isDataValid()) { continue; }
double volts = upController->getData()->V;
if (min == -1) { min = volts; }
min = std::min(min, volts);

View File

@ -34,7 +34,3 @@ void VictronSmartShunt::loop()
_stats->updateFrom(VeDirectShunt.veFrame);
_lastUpdate = VeDirectShunt.getLastUpdate();
}
bool VictronSmartShunt::usesHwPort2() {
return true;
}

View File

@ -55,25 +55,28 @@ void WebApiWsVedirectLiveClass::wsCleanupTaskCb()
void WebApiWsVedirectLiveClass::sendDataTaskCb()
{
// do nothing if no WS client is connected
if (_ws.count() == 0) {
return;
}
// we assume this loop to be running at least twice for every
// update from a VE.Direct MPPT data producer, so _dataAgeMillis
// actually grows in between updates.
auto lastDataAgeMillis = _dataAgeMillis;
_dataAgeMillis = VictronMppt.getDataAgeMillis();
if (_ws.count() == 0) { return; }
// Update on ve.direct change or at least after 10 seconds
if (millis() - _lastWsPublish > (10 * 1000) || lastDataAgeMillis > _dataAgeMillis) {
bool fullUpdate = (millis() - _lastFullPublish > (10 * 1000));
bool updateAvailable = false;
if (!fullUpdate) {
for (int idx = 0; idx < VICTRON_MAX_COUNT; ++idx) {
auto currentAgeMillis = VictronMppt.getDataAgeMillis(idx);
if (currentAgeMillis > 0 && currentAgeMillis < _dataAgeMillis[idx]) {
updateAvailable = true;
break;
}
}
}
if (fullUpdate || updateAvailable) {
try {
std::lock_guard<std::mutex> lock(_mutex);
DynamicJsonDocument root(_responseSize);
if (Utils::checkJsonAlloc(root, __FUNCTION__, __LINE__)) {
JsonVariant var = root;
generateJsonResponse(var);
generateJsonResponse(var, fullUpdate);
String buffer;
serializeJson(root, buffer);
@ -92,15 +95,17 @@ void WebApiWsVedirectLiveClass::sendDataTaskCb()
} catch (const std::exception& exc) {
MessageOutput.printf("Unknown exception in /api/vedirectlivedata/status. Reason: \"%s\".\r\n", exc.what());
}
}
_lastWsPublish = millis();
if (fullUpdate) {
_lastFullPublish = millis();
}
}
void WebApiWsVedirectLiveClass::generateJsonResponse(JsonVariant& root)
void WebApiWsVedirectLiveClass::generateJsonResponse(JsonVariant& root, bool fullUpdate)
{
root["vedirect"]["data_age"] = VictronMppt.getDataAgeMillis() / 1000;
const JsonArray &array = root["vedirect"].createNestedArray("devices");
const JsonObject &array = root["vedirect"].createNestedObject("instances");
root["vedirect"]["full_update"] = fullUpdate;
for (int idx = 0; idx < VICTRON_MAX_COUNT; ++idx) {
std::optional<VeDirectMpptController::spData_t> spOptMpptData = VictronMppt.getData(idx);
@ -108,10 +113,16 @@ void WebApiWsVedirectLiveClass::generateJsonResponse(JsonVariant& root)
continue;
}
auto lastDataAgeMillis = _dataAgeMillis[idx];
_dataAgeMillis[idx] = VictronMppt.getDataAgeMillis(idx);
bool validAge = _dataAgeMillis[idx] > 0;
bool updateAvailable = _dataAgeMillis[idx] < lastDataAgeMillis;
if (!fullUpdate && !(validAge && updateAvailable)) { continue; }
VeDirectMpptController::spData_t &spMpptData = spOptMpptData.value();
const JsonObject &nested = array.createNestedObject();
nested["age_critical"] = !VictronMppt.isDataValid(idx);
const JsonObject &nested = array.createNestedObject(spMpptData->SER);
nested["data_age_ms"] = _dataAgeMillis[idx];
populateJson(nested, spMpptData);
}
@ -122,8 +133,7 @@ void WebApiWsVedirectLiveClass::generateJsonResponse(JsonVariant& root)
root["dpl"]["PLLIMIT"] = PowerLimiter.getLastRequestedPowerLimit();
}
void
WebApiWsVedirectLiveClass::populateJson(const JsonObject &root, const VeDirectMpptController::spData_t &spMpptData) {
void WebApiWsVedirectLiveClass::populateJson(const JsonObject &root, const VeDirectMpptController::spData_t &spMpptData) {
// device info
root["device"]["PID"] = spMpptData->getPidAsString();
root["device"]["SER"] = spMpptData->SER;
@ -202,7 +212,7 @@ void WebApiWsVedirectLiveClass::onLivedataStatus(AsyncWebServerRequest* request)
AsyncJsonResponse* response = new AsyncJsonResponse(false, _responseSize);
auto& root = response->getRoot();
generateJsonResponse(root);
generateJsonResponse(root, true/*fullUpdate*/);
response->setLength();
request->send(response);

View File

@ -9,11 +9,11 @@
<template v-else>
<div class="row gy-3">
<div class="tab-content col-sm-12 col-md-12" id="v-pills-tabContent">
<div class="card" v-for="item in vedirect.devices">
<div class="card" v-for="(item, serial) in vedirect.instances" :key="serial">
<div class="card-header d-flex justify-content-between align-items-center"
:class="{
'text-bg-danger': item.age_critical,
'text-bg-primary': !item.age_critical,
'text-bg-danger': item.data_age_ms >= 10000,
'text-bg-primary': item.data_age_ms < 10000,
}">
<div class="p-1 flex-grow-1">
<div class="d-flex flex-wrap">
@ -27,7 +27,7 @@
{{ $t('vedirecthome.FirmwareNumber') }} {{ item.device.FW }}
</div>
<div style="padding-right: 2em;">
{{ $t('vedirecthome.DataAge') }} {{ $t('vedirecthome.Seconds', {'val': vedirect.data_age }) }}
{{ $t('vedirecthome.DataAge') }} {{ $t('vedirecthome.Seconds', {'val': Math.floor(item.data_age_ms / 1000)}) }}
</div>
</div>
</div>
@ -199,7 +199,7 @@ export default defineComponent({
return {
socket: {} as WebSocket,
heartInterval: 0,
dataAgeInterval: 0,
dataAgeTimers: {} as Record<string, number>,
dataLoading: true,
dplData: {} as DynamicPowerLimiter,
vedirect: {} as Vedirect,
@ -209,7 +209,6 @@ export default defineComponent({
created() {
this.getInitialData();
this.initSocket();
this.initDataAgeing();
},
unmounted() {
this.closeSocket();
@ -224,6 +223,7 @@ export default defineComponent({
this.dplData = root["dpl"];
this.vedirect = root["vedirect"];
this.dataLoading = false;
this.resetDataAging(Object.keys(root["vedirect"]["instances"]));
});
},
initSocket() {
@ -240,7 +240,12 @@ export default defineComponent({
console.log(event);
var root = JSON.parse(event.data);
this.dplData = root["dpl"];
this.vedirect = root["vedirect"];
if (root["vedirect"]["full_update"] === true) {
this.vedirect = root["vedirect"];
} else {
Object.assign(this.vedirect.instances, root["vedirect"]["instances"]);
}
this.resetDataAging(Object.keys(root["vedirect"]["instances"]));
this.dataLoading = false;
this.heartCheck(); // Reset heartbeat detection
};
@ -255,11 +260,25 @@ export default defineComponent({
this.closeSocket();
};
},
initDataAgeing() {
this.dataAgeInterval = setInterval(() => {
if (this.vedirect) {
this.vedirect.data_age++;
resetDataAging(serials: Array<string>) {
serials.forEach((serial) => {
if (this.dataAgeTimers[serial] !== undefined) {
clearTimeout(this.dataAgeTimers[serial]);
}
var nextMs = 1000 - (this.vedirect.instances[serial].data_age_ms % 1000);
this.dataAgeTimers[serial] = setTimeout(() => {
this.doDataAging(serial);
}, nextMs);
});
},
doDataAging(serial: string) {
if (this.vedirect?.instances?.[serial] === undefined) { return; }
this.vedirect.instances[serial].data_age_ms += 1000;
this.dataAgeTimers[serial] = setTimeout(() => {
this.doDataAging(serial);
}, 1000);
},
// Send heartbeat packets regularly * 59s Send a heartbeat
@ -280,11 +299,6 @@ export default defineComponent({
this.heartInterval && clearTimeout(this.heartInterval);
this.isFirstFetchAfterConnect = true;
},
formatNumber(num: number) {
return new Intl.NumberFormat(
undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }
).format(num);
},
},
});
</script>

View File

@ -6,15 +6,15 @@ export interface DynamicPowerLimiter {
}
export interface Vedirect {
data_age: 0;
devices: Array<VedirectDevices>;
full_update: boolean;
instances: { [key: string]: VedirectInstance };
}
export interface VedirectDevices {
age_critical: boolean;
export interface VedirectInstance {
data_age_ms: number;
device: VedirectDevice;
input: VedirectInput;
output: VedirectOutput;
input: VedirectInput;
}
export interface VedirectDevice {