sunspec: add Fronius vendor with its own control rules
Adds a fronius vendor and a froniusConnection thing class alongside the
generic sunspec and solarEdge ones, following the SolarEdge mechanism but
none of its workarounds - no proprietary battery block and no PV
production computed by difference, since the GEN24 exposes model 160.
FroniusRules holds the vendor decisions as pure functions:
- discovery only keeps devices announcing Fronius as manufacturer;
- the byte order is the SunSpec default, not the forced big endian that
is specific to SolarEdge, and no endianness parameter is exposed;
- meterSlaveId defaults to 200, 0 meaning no meter on this connection;
- a hard 7 s floor on the poll interval. Polling faster can push the
inverter offline towards the vendor portal, and recovery needs an
AC+DC power cycle on site, so this is a guard in the code rather than
a configurable default;
- the registers the inverter accepts and then silently ignores are
refused before anything reaches the wire;
- the six HEMS battery modes map onto ChaGriSet, StorCtl_Mod, InWRte and
OutWRte, negative meaning charge;
- model 160 blocks are classified by IDStr, never by index: on a three
or four MPPT inverter the battery blocks are not in position 3 and 4.
Storages under a Fronius connection default revertTime to 300 s.
Setpoints now reread the scale factor before writing and read the written
registers back afterwards, reporting a mismatch through a new
controlHealthy state - the only defence against writes that are accepted
and then ignored without a Modbus exception.
Byte order and the revert timer semantics on model 124 are inferences, not
measurements; see specs/fronius-sunspec.md, H-2 and H-3.
This commit is contained in:
parent
c5c3f46f57
commit
89406495a8
172
sunspec/froniusrules.cpp
Normal file
172
sunspec/froniusrules.cpp
Normal file
@ -0,0 +1,172 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
*
|
||||
* Copyright (C) 2026, ETM-PowerSync
|
||||
*
|
||||
* This file is part of nymea.
|
||||
*
|
||||
* nymea is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* nymea. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#include "froniusrules.h"
|
||||
|
||||
using namespace SunSpecStorageLogic;
|
||||
|
||||
namespace FroniusRules {
|
||||
|
||||
bool matchesManufacturer(const QStringList &manufacturers)
|
||||
{
|
||||
foreach (const QString &name, manufacturers) {
|
||||
if (name.contains(QStringLiteral("fronius"), Qt::CaseInsensitive))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
SunSpecDataPoint::ByteOrder byteOrder()
|
||||
{
|
||||
return SunSpecDataPoint::ByteOrderLittleEndian;
|
||||
}
|
||||
|
||||
int clampUpdateInterval(int requestedSeconds)
|
||||
{
|
||||
return qMax(requestedSeconds, MinimumUpdateIntervalSeconds);
|
||||
}
|
||||
|
||||
bool isWriteForbidden(const QString ®isterName)
|
||||
{
|
||||
static const QStringList forbidden = {
|
||||
QStringLiteral("WChaMax"),
|
||||
QStringLiteral("WChaGra"),
|
||||
QStringLiteral("WDisChaGra"),
|
||||
QStringLiteral("InOutWRte_WinTms"),
|
||||
QStringLiteral("InOutWRte_RmpTms"),
|
||||
/* VAChaMax is doubly unusable: the write is ignored and its scale
|
||||
* factor is not supported either. */
|
||||
QStringLiteral("VAChaMax")
|
||||
};
|
||||
|
||||
foreach (const QString &name, forbidden) {
|
||||
if (registerName.compare(name, Qt::CaseInsensitive) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
SetpointPlan planBatteryMode(BatteryMode mode, double chargeLimit, double dischargeLimit, quint16 revertTime)
|
||||
{
|
||||
/* StorCtl_Mod is a bitfield: bit 0 CHARGE, bit 1 DISCHARGE. ChaGriSet is
|
||||
* 0 PV, 1 GRID. A register absent from the vector below is deliberately not
|
||||
* written for that mode.
|
||||
*
|
||||
* planSetpoint() puts these in the mandated order and appends the revert
|
||||
* timer, so the order they are listed in here does not matter. */
|
||||
QVector<Write> setpoints;
|
||||
|
||||
switch (mode) {
|
||||
case BatteryModeNormal:
|
||||
setpoints.append({RegisterStorCtlMod, 0});
|
||||
setpoints.append({RegisterOutWRte, 100});
|
||||
break;
|
||||
|
||||
case BatteryModeNoDischarge:
|
||||
setpoints.append({RegisterStorCtlMod, 2});
|
||||
setpoints.append({RegisterOutWRte, 0});
|
||||
break;
|
||||
|
||||
case BatteryModeNoCharge:
|
||||
setpoints.append({RegisterStorCtlMod, 1});
|
||||
setpoints.append({RegisterInWRte, 0});
|
||||
break;
|
||||
|
||||
case BatteryModeFrozen:
|
||||
setpoints.append({RegisterStorCtlMod, 3});
|
||||
setpoints.append({RegisterInWRte, 0});
|
||||
setpoints.append({RegisterOutWRte, 0});
|
||||
break;
|
||||
|
||||
case BatteryModeForceCharge:
|
||||
/* Charging from the grid has to be allowed on the inverter as well;
|
||||
* ChaGriSet alone is ANDed with a setting of the component
|
||||
* configuration and fails silently if that one is off. */
|
||||
setpoints.append({RegisterChaGriSet, 1});
|
||||
setpoints.append({RegisterStorCtlMod, 2});
|
||||
/* Negative is charging. */
|
||||
setpoints.append({RegisterOutWRte, -chargeLimit});
|
||||
break;
|
||||
|
||||
case BatteryModeLimit:
|
||||
setpoints.append({RegisterStorCtlMod, 3});
|
||||
setpoints.append({RegisterInWRte, chargeLimit});
|
||||
setpoints.append({RegisterOutWRte, dischargeLimit});
|
||||
break;
|
||||
}
|
||||
|
||||
return planSetpoint(setpoints, revertTime);
|
||||
}
|
||||
|
||||
BlockRole classifyBlock(const QString &inputIdString)
|
||||
{
|
||||
const QString id = inputIdString.trimmed();
|
||||
if (id.isEmpty())
|
||||
return BlockRoleUnknown;
|
||||
|
||||
/* Prefix match, not contains: the role is decided by the string, never by
|
||||
* the block index. StDisCha is checked first so that no cascade can ever
|
||||
* swallow it into the charge case. */
|
||||
if (id.startsWith(QStringLiteral("StDisCha"), Qt::CaseInsensitive))
|
||||
return BlockRoleBatteryDischarge;
|
||||
|
||||
if (id.startsWith(QStringLiteral("StCha"), Qt::CaseInsensitive))
|
||||
return BlockRoleBatteryCharge;
|
||||
|
||||
if (id.startsWith(QStringLiteral("MPPT"), Qt::CaseInsensitive))
|
||||
return BlockRolePvString;
|
||||
|
||||
return BlockRoleUnknown;
|
||||
}
|
||||
|
||||
double pvPower(const QList<ModuleReading> &readings)
|
||||
{
|
||||
double total = 0;
|
||||
foreach (const ModuleReading &reading, readings) {
|
||||
if (classifyBlock(reading.inputIdString) == BlockRolePvString)
|
||||
total += reading.dcPower;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
double batteryPower(const QList<ModuleReading> &readings)
|
||||
{
|
||||
double charging = 0;
|
||||
double discharging = 0;
|
||||
|
||||
foreach (const ModuleReading &reading, readings) {
|
||||
switch (classifyBlock(reading.inputIdString)) {
|
||||
case BlockRoleBatteryCharge:
|
||||
charging += reading.dcPower;
|
||||
break;
|
||||
case BlockRoleBatteryDischarge:
|
||||
discharging += reading.dcPower;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return discharging - charging;
|
||||
}
|
||||
|
||||
}
|
||||
126
sunspec/froniusrules.h
Normal file
126
sunspec/froniusrules.h
Normal file
@ -0,0 +1,126 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
*
|
||||
* Copyright (C) 2026, ETM-PowerSync
|
||||
*
|
||||
* This file is part of nymea.
|
||||
*
|
||||
* nymea is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* nymea. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef FRONIUSRULES_H
|
||||
#define FRONIUSRULES_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <sunspecdatapoint.h>
|
||||
|
||||
#include "sunspecstoragelogic.h"
|
||||
|
||||
/* Vendor specific rules for Fronius GEN24 inverters.
|
||||
*
|
||||
* Everything here is a pure decision: no Modbus, no thing, no timer. The
|
||||
* plugin asks these functions what to do; the answers are what the unit tests
|
||||
* pin down. See specs/fronius-sunspec.md.
|
||||
*/
|
||||
namespace FroniusRules {
|
||||
|
||||
/* Polling. The manufacturer's support recommends staying above seven seconds
|
||||
* for third party applications. Below that, communication load can push the
|
||||
* inverter offline towards the manufacturer's portal, and recovery needs an
|
||||
* AC+DC power cycle on site. This floor is a guard in the code, not a
|
||||
* configurable default. */
|
||||
const int MinimumUpdateIntervalSeconds = 7;
|
||||
const int DefaultUpdateIntervalSeconds = 10;
|
||||
const int DefaultTimeoutMilliSeconds = 2000;
|
||||
const int DefaultNumberOfRetries = 3;
|
||||
|
||||
/* Revert timer armed with every setpoint, so that a setpoint left behind by a
|
||||
* stopped or crashed HEMS expires instead of holding the battery forever.
|
||||
*
|
||||
* WORKING HYPOTHESIS: the timer semantics are documented in the manual's
|
||||
* general chapter and illustrated on model 123, not on model 124. This must be
|
||||
* verified experimentally on model 124 before being frozen. If it does not
|
||||
* hold, fall back to 0 plus an application level watchdog in the HEMS. */
|
||||
const int DefaultRevertTimeSeconds = 300;
|
||||
|
||||
/* Default unit IDs. The inverter is fixed at 1 over TCP; the meter defaults to
|
||||
* 200, with 201, 202... for secondary meters. 0 means "no meter on this
|
||||
* connection". */
|
||||
const int InverterSlaveId = 1;
|
||||
const int DefaultMeterSlaveId = 200;
|
||||
|
||||
bool matchesManufacturer(const QStringList &manufacturers);
|
||||
|
||||
/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer,
|
||||
* so the SunSpec default is assumed. The forced big endian of the SolarEdge
|
||||
* vendor is an anomaly specific to SolarEdge and must not be extended here.
|
||||
* To be confirmed on the bench, then on hardware. */
|
||||
SunSpecDataPoint::ByteOrder byteOrder();
|
||||
|
||||
int clampUpdateInterval(int requestedSeconds);
|
||||
|
||||
/* Registers the GEN24 accepts and then ignores without raising a Modbus
|
||||
* exception. Writing them fails invisibly, so they are refused before being
|
||||
* put on the wire. */
|
||||
bool isWriteForbidden(const QString ®isterName);
|
||||
|
||||
enum BatteryMode {
|
||||
BatteryModeNormal,
|
||||
BatteryModeNoDischarge,
|
||||
BatteryModeNoCharge,
|
||||
BatteryModeFrozen,
|
||||
BatteryModeForceCharge,
|
||||
BatteryModeLimit
|
||||
};
|
||||
|
||||
/* Sign convention of the manufacturer: negative is charging, positive is
|
||||
* discharging. chargeLimit and dischargeLimit are percentages of WChaMax and
|
||||
* are only used by the modes that need them. */
|
||||
SunSpecStorageLogic::SetpointPlan planBatteryMode(BatteryMode mode,
|
||||
double chargeLimit,
|
||||
double dischargeLimit,
|
||||
quint16 revertTime);
|
||||
|
||||
/* Model 160 repeating blocks are identified by their IDStr, never by their
|
||||
* index: on a three or four MPPT inverter the battery blocks are not in
|
||||
* position 3 and 4. */
|
||||
enum BlockRole {
|
||||
BlockRolePvString,
|
||||
BlockRoleBatteryCharge,
|
||||
BlockRoleBatteryDischarge,
|
||||
BlockRoleUnknown
|
||||
};
|
||||
|
||||
BlockRole classifyBlock(const QString &inputIdString);
|
||||
|
||||
struct ModuleReading {
|
||||
QString inputIdString;
|
||||
double dcPower;
|
||||
};
|
||||
|
||||
/* Sum of the DC power of the PV string blocks. */
|
||||
double pvPower(const QList<ModuleReading> &readings);
|
||||
|
||||
/* Discharge minus charge, so positive means discharging. DCW is a uint16 and
|
||||
* therefore always positive: the sign is carried by whichever block is active,
|
||||
* the inactive one reading zero. */
|
||||
double batteryPower(const QList<ModuleReading> &readings);
|
||||
|
||||
}
|
||||
|
||||
#endif // FRONIUSRULES_H
|
||||
@ -66,21 +66,27 @@ void IntegrationPluginSunSpec::init()
|
||||
// SunSpec connection params
|
||||
m_connectionDiscoverySlaveIdParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionDiscoverySlaveIdParamTypeId);
|
||||
m_connectionDiscoverySlaveIdParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionDiscoverySlaveIdParamTypeId);
|
||||
m_connectionDiscoverySlaveIdParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionDiscoverySlaveIdParamTypeId);
|
||||
|
||||
m_connectionMacAddressParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingMacAddressParamTypeId);
|
||||
m_connectionMacAddressParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingMacAddressParamTypeId);
|
||||
m_connectionMacAddressParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingMacAddressParamTypeId);
|
||||
|
||||
m_connectionHostNameParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingHostNameParamTypeId);
|
||||
m_connectionHostNameParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingHostNameParamTypeId);
|
||||
m_connectionHostNameParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingHostNameParamTypeId);
|
||||
|
||||
m_connectionAddressParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingAddressParamTypeId);
|
||||
m_connectionAddressParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingAddressParamTypeId);
|
||||
m_connectionAddressParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingAddressParamTypeId);
|
||||
|
||||
m_connectionPortParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingPortParamTypeId);
|
||||
m_connectionPortParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingPortParamTypeId);
|
||||
m_connectionPortParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingPortParamTypeId);
|
||||
|
||||
m_connectionSlaveIdParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingSlaveIdParamTypeId);
|
||||
m_connectionSlaveIdParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingSlaveIdParamTypeId);
|
||||
m_connectionSlaveIdParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingSlaveIdParamTypeId);
|
||||
|
||||
// Params for sunspec things
|
||||
m_modelIdParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingModelIdParamTypeId);
|
||||
@ -140,6 +146,8 @@ void IntegrationPluginSunSpec::discoverThings(ThingDiscoveryInfo *info)
|
||||
SunSpecDataPoint::ByteOrder byteOrder = SunSpecDataPoint::ByteOrderLittleEndian;
|
||||
if (info->thingClassId() == solarEdgeConnectionThingClassId)
|
||||
byteOrder = SunSpecDataPoint::ByteOrderBigEndian;
|
||||
else if (info->thingClassId() == froniusConnectionThingClassId)
|
||||
byteOrder = FroniusRules::byteOrder();
|
||||
|
||||
quint16 discoverySlaveId = info->params().paramValue(m_connectionDiscoverySlaveIdParamTypeIds.value(info->thingClassId())).toUInt();
|
||||
|
||||
@ -165,6 +173,12 @@ void IntegrationPluginSunSpec::discoverThings(ThingDiscoveryInfo *info)
|
||||
} else {
|
||||
manufacturer = "SolarEdge";
|
||||
}
|
||||
} else if (info->thingClassId() == froniusConnectionThingClassId) {
|
||||
if (!FroniusRules::matchesManufacturer(result.modelManufacturers)) {
|
||||
// Only offer devices that actually announce themselves as Fronius.
|
||||
continue;
|
||||
}
|
||||
manufacturer = "Fronius";
|
||||
} else if (info->thingClassId() == sunspecConnectionThingClassId) {
|
||||
// There are some issues regarding the sunspec implementation of kostal.
|
||||
// Full support of meter, inverter and storage will be provided in the kostal plugin which makes
|
||||
@ -416,7 +430,7 @@ void IntegrationPluginSunSpec::postSetupThing(Thing *thing)
|
||||
// Create the refresh timer if not already set up
|
||||
if (!m_refreshTimer) {
|
||||
qCDebug(dcSunSpec()) << "Starting refresh timer";
|
||||
int refreshTime = configValue(sunSpecPluginUpdateIntervalParamTypeId).toInt();
|
||||
int refreshTime = effectiveUpdateInterval();
|
||||
m_refreshTimer = hardwareManager()->pluginTimerManager()->registerTimer(refreshTime);
|
||||
connect(m_refreshTimer, &PluginTimer::timeout, this, &IntegrationPluginSunSpec::onRefreshTimer);
|
||||
}
|
||||
@ -509,6 +523,36 @@ void IntegrationPluginSunSpec::executeAction(ThingActionInfo *info)
|
||||
}
|
||||
}
|
||||
|
||||
/* The refresh timer is plugin wide, not per connection, so the Fronius polling
|
||||
* floor has to be applied to it: as soon as one Fronius connection is
|
||||
* configured, the whole plugin stops polling faster than the floor.
|
||||
*
|
||||
* Going below it can push the inverter offline towards the manufacturer's
|
||||
* portal, and recovery then needs an AC+DC power cycle on site. This is a guard
|
||||
* in the code on purpose, not a configurable default - including in debug. */
|
||||
int IntegrationPluginSunSpec::effectiveUpdateInterval()
|
||||
{
|
||||
const int configured = configValue(sunSpecPluginUpdateIntervalParamTypeId).toInt();
|
||||
|
||||
if (myThings().filterByThingClassId(froniusConnectionThingClassId).isEmpty())
|
||||
return configured;
|
||||
|
||||
const int clamped = FroniusRules::clampUpdateInterval(configured);
|
||||
if (clamped != configured) {
|
||||
qCWarning(dcSunSpec()) << "Update interval of" << configured << "s is below the"
|
||||
<< FroniusRules::MinimumUpdateIntervalSeconds
|
||||
<< "s floor required while a Fronius connection is configured. Using"
|
||||
<< clamped << "s instead.";
|
||||
}
|
||||
return clamped;
|
||||
}
|
||||
|
||||
bool IntegrationPluginSunSpec::isFroniusChild(const ThingId &parentId)
|
||||
{
|
||||
Thing *parent = myThings().findById(parentId);
|
||||
return parent && parent->thingClassId() == froniusConnectionThingClassId;
|
||||
}
|
||||
|
||||
QModbusReply *IntegrationPluginSunSpec::writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write)
|
||||
{
|
||||
switch (write.reg) {
|
||||
@ -533,12 +577,32 @@ QModbusReply *IntegrationPluginSunSpec::writeSetpointRegister(SunSpecStorageMode
|
||||
void IntegrationPluginSunSpec::executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index)
|
||||
{
|
||||
if (index >= plan.writes.count()) {
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
if (plan.verifyReadback.isEmpty()) {
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
return;
|
||||
}
|
||||
|
||||
refreshStorageBlock(info, storage, [this, info, storage, plan]() {
|
||||
verifySetpointPlan(info, storage, plan);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const SunSpecStorageLogic::Write write = plan.writes.at(index);
|
||||
|
||||
/* Defence in depth: a Fronius inverter accepts some writes and then ignores
|
||||
* them without raising a Modbus exception, so the failure is invisible.
|
||||
* Those registers are refused before anything goes on the wire rather than
|
||||
* silently pretending to have worked. */
|
||||
if (isFroniusChild(info->thing()->parentId())
|
||||
&& FroniusRules::isWriteForbidden(SunSpecStorageLogic::registerName(write.reg))) {
|
||||
qCWarning(dcSunSpec()) << "Refusing to write" << SunSpecStorageLogic::registerName(write.reg)
|
||||
<< "on" << info->thing()
|
||||
<< "- this register is ignored by the inverter without raising an exception.";
|
||||
info->finish(Thing::ThingErrorHardwareFailure);
|
||||
return;
|
||||
}
|
||||
|
||||
QModbusReply *reply = writeSetpointRegister(storage, write);
|
||||
if (!reply) {
|
||||
qCWarning(dcSunSpec()) << "Could not write" << SunSpecStorageLogic::registerName(write.reg) << "on" << info->thing();
|
||||
@ -559,9 +623,95 @@ void IntegrationPluginSunSpec::executeSetpointWrite(ThingActionInfo *info, SunSp
|
||||
});
|
||||
}
|
||||
|
||||
double IntegrationPluginSunSpec::readSetpointRegister(SunSpecStorageModel *storage, SunSpecStorageLogic::Register reg)
|
||||
{
|
||||
switch (reg) {
|
||||
case SunSpecStorageLogic::RegisterChaGriSet:
|
||||
return storage->chaGriSet();
|
||||
case SunSpecStorageLogic::RegisterStorCtlMod:
|
||||
return int(storage->storCtlMod());
|
||||
case SunSpecStorageLogic::RegisterInWRte:
|
||||
return storage->inWRte();
|
||||
case SunSpecStorageLogic::RegisterOutWRte:
|
||||
return storage->outWRte();
|
||||
case SunSpecStorageLogic::RegisterRvrtTms:
|
||||
return storage->inOutWRteRvrtTms();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Reads the model block again and calls the continuation once the fresh values
|
||||
* are in. The guard timer keeps a lost or unanswered read from stranding the
|
||||
* action forever; the continuation then runs on stale values, which is why it
|
||||
* warns. */
|
||||
void IntegrationPluginSunSpec::refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation)
|
||||
{
|
||||
QObject *context = new QObject(info);
|
||||
QSharedPointer<bool> fired = QSharedPointer<bool>::create(false);
|
||||
|
||||
auto proceed = [context, fired, continuation](bool timedOut) {
|
||||
if (*fired)
|
||||
return;
|
||||
*fired = true;
|
||||
if (timedOut)
|
||||
qCWarning(dcSunSpec()) << "Timed out waiting for a fresh storage block; continuing on the previous values.";
|
||||
context->deleteLater();
|
||||
continuation();
|
||||
};
|
||||
|
||||
connect(storage, &SunSpecModel::blockUpdated, context, [proceed]() { proceed(false); });
|
||||
|
||||
QTimer *guard = new QTimer(context);
|
||||
guard->setSingleShot(true);
|
||||
guard->setInterval(FroniusRules::DefaultTimeoutMilliSeconds * (FroniusRules::DefaultNumberOfRetries + 1));
|
||||
connect(guard, &QTimer::timeout, context, [proceed]() { proceed(true); });
|
||||
guard->start();
|
||||
|
||||
storage->readBlockData();
|
||||
}
|
||||
|
||||
/* Write, read back, compare. An inverter may accept a write and then ignore it
|
||||
* without raising a Modbus exception - control not enabled on the device, grid
|
||||
* charging not allowed, an IP restriction, or another controller overwriting
|
||||
* the setpoints. An unverified setpoint is an unapplied setpoint. */
|
||||
void IntegrationPluginSunSpec::verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan)
|
||||
{
|
||||
bool healthy = true;
|
||||
|
||||
foreach (const SunSpecStorageLogic::Write &write, plan.writes) {
|
||||
if (!plan.verifyReadback.contains(write.reg))
|
||||
continue;
|
||||
|
||||
const double readBack = readSetpointRegister(storage, write.reg);
|
||||
|
||||
/* The rates carry a scale factor of -2, so anything within a hundredth
|
||||
* is the same value. The enums and the bitfield compare exactly. */
|
||||
const double tolerance = SunSpecStorageLogic::isScaledRate(write.reg) ? 0.011 : 0.0001;
|
||||
if (qAbs(readBack - write.value) > tolerance) {
|
||||
qCWarning(dcSunSpec()) << "Setpoint not applied on" << info->thing() << ":"
|
||||
<< SunSpecStorageLogic::registerName(write.reg)
|
||||
<< "was written as" << write.value << "but reads back as" << readBack;
|
||||
healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
info->thing()->setStateValue(sunspecStorageControlHealthyStateTypeId, healthy);
|
||||
info->finish(Thing::ThingErrorNoError);
|
||||
}
|
||||
|
||||
void IntegrationPluginSunSpec::executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan)
|
||||
{
|
||||
executeSetpointWrite(info, storage, plan, 0);
|
||||
if (!plan.rereadScaleFactor) {
|
||||
executeSetpointWrite(info, storage, plan, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Scale factors are not guaranteed static: they may change with firmware
|
||||
* and at runtime. A factor cached during an earlier poll can put the
|
||||
* setpoint an order of magnitude off, so refresh it right before writing. */
|
||||
refreshStorageBlock(info, storage, [this, info, storage, plan]() {
|
||||
executeSetpointWrite(info, storage, plan, 0);
|
||||
});
|
||||
}
|
||||
|
||||
Thing *IntegrationPluginSunSpec::getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId)
|
||||
@ -716,6 +866,9 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing)
|
||||
// Note: for some reason, solar edge is using big endian register order instead
|
||||
// of little endian as specified in sunspec and even solar edge documentation
|
||||
connection = new SunSpecConnection(address, port, slaveId, SunSpecDataPoint::ByteOrderBigEndian, this);
|
||||
} else if (thing->thingClassId() == froniusConnectionThingClassId) {
|
||||
// No endianness parameter here: the byte order is imposed, not chosen.
|
||||
connection = new SunSpecConnection(address, port, slaveId, FroniusRules::byteOrder(), this);
|
||||
} else {
|
||||
SunSpecDataPoint::ByteOrder endianness = SunSpecDataPoint::ByteOrderLittleEndian;
|
||||
QString endiannessParam = thing->paramValue("endianness").toString();
|
||||
@ -724,8 +877,14 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing)
|
||||
|
||||
connection = new SunSpecConnection(address, port, slaveId, endianness, this);
|
||||
}
|
||||
connection->setTimeout(configValue(sunSpecPluginTimeoutParamTypeId).toUInt());
|
||||
connection->setNumberOfRetries(configValue(sunSpecPluginNumberOfRetriesParamTypeId).toUInt());
|
||||
|
||||
if (thing->thingClassId() == froniusConnectionThingClassId) {
|
||||
connection->setTimeout(FroniusRules::DefaultTimeoutMilliSeconds);
|
||||
connection->setNumberOfRetries(FroniusRules::DefaultNumberOfRetries);
|
||||
} else {
|
||||
connection->setTimeout(configValue(sunSpecPluginTimeoutParamTypeId).toUInt());
|
||||
connection->setNumberOfRetries(configValue(sunSpecPluginNumberOfRetriesParamTypeId).toUInt());
|
||||
}
|
||||
|
||||
// Reconnect on monitor reachable changed
|
||||
NetworkDeviceMonitor *monitor = m_monitors.value(thing);
|
||||
@ -979,6 +1138,15 @@ void IntegrationPluginSunSpec::autocreateSunSpecModelThing(const ThingClassId &t
|
||||
params.append(Param(m_manufacturerParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().manufacturerName));
|
||||
params.append(Param(m_deviceModelParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().modelName));
|
||||
params.append(Param(m_serialNumberParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().serialNumber));
|
||||
|
||||
/* A storage created under a Fronius connection arms the revert timer by
|
||||
* default, where the generic vendor leaves it disabled. A setpoint left
|
||||
* behind by a stopped HEMS would otherwise hold the customer's battery
|
||||
* indefinitely. */
|
||||
if (thingClassId == sunspecStorageThingClassId && isFroniusChild(parentId)) {
|
||||
params.append(Param(sunspecStorageThingRevertTimeParamTypeId, FroniusRules::DefaultRevertTimeSeconds));
|
||||
}
|
||||
|
||||
descriptor.setParams(params);
|
||||
emit autoThingsAppeared({descriptor});
|
||||
}
|
||||
@ -1236,7 +1404,7 @@ void IntegrationPluginSunSpec::onPluginConfigurationChanged(const ParamTypeId &p
|
||||
if (paramTypeId == sunSpecPluginUpdateIntervalParamTypeId) {
|
||||
qCDebug(dcSunSpec()) << "Update interval has changed" << value.toInt();
|
||||
if (m_refreshTimer) {
|
||||
int refreshTime = value.toInt();
|
||||
int refreshTime = effectiveUpdateInterval();
|
||||
m_refreshTimer->stop();
|
||||
hardwareManager()->pluginTimerManager()->unregisterTimer(m_refreshTimer);
|
||||
m_refreshTimer = hardwareManager()->pluginTimerManager()->registerTimer(refreshTime);
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
|
||||
#include "sunspecthing.h"
|
||||
#include "sunspecstoragelogic.h"
|
||||
#include "froniusrules.h"
|
||||
#include "extern-plugininfo.h"
|
||||
|
||||
#include <QUuid>
|
||||
@ -109,10 +110,17 @@ private:
|
||||
bool hasManufacturer(const QStringList &manufacturers, const QString &manufacturer);
|
||||
void markThingStatesDisconnected(Thing *thing);
|
||||
|
||||
// Fronius
|
||||
bool isFroniusChild(const ThingId &parentId);
|
||||
int effectiveUpdateInterval();
|
||||
|
||||
// Storage setpoints
|
||||
void executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
|
||||
void executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index);
|
||||
QModbusReply *writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write);
|
||||
double readSetpointRegister(SunSpecStorageModel *storage, SunSpecStorageLogic::Register reg);
|
||||
void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function<void()> &continuation);
|
||||
void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
|
||||
|
||||
private slots:
|
||||
void onRefreshTimer();
|
||||
|
||||
@ -1472,6 +1472,14 @@
|
||||
"writable": true,
|
||||
"displayNameAction": "Set discharging rate"
|
||||
},
|
||||
{
|
||||
"id": "ed738d23-4bea-40e9-a41e-ce6f37f1a3e2",
|
||||
"name": "controlHealthy",
|
||||
"displayName": "Control healthy",
|
||||
"displayNameEvent": "Control healthy changed",
|
||||
"type": "bool",
|
||||
"defaultValue": true
|
||||
},
|
||||
{
|
||||
"id": "47035ac0-d054-491a-969d-b2aaa9168b0a",
|
||||
"name": "chargeGradient",
|
||||
@ -1751,6 +1759,91 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fronius",
|
||||
"displayName": "Fronius",
|
||||
"id": "8504de60-2625-4ad4-8889-623a8d0453ff",
|
||||
"thingClasses": [
|
||||
{
|
||||
"name": "froniusConnection",
|
||||
"displayName": "Fronius",
|
||||
"id": "a4312ee8-ed15-4378-85e2-0372cef7de03",
|
||||
"createMethods": [ "discovery", "user" ],
|
||||
"interfaces": [ "gateway", "networkdevice" ],
|
||||
"providedInterfaces": [ "solarinverter", "energymeter", "energystorage" ],
|
||||
"discoveryParamTypes": [
|
||||
{
|
||||
"id": "c1d4df93-cc65-4766-a1b3-7c26edf986c0",
|
||||
"name":"slaveId",
|
||||
"displayName": "Slave ID",
|
||||
"type": "int",
|
||||
"defaultValue": 1
|
||||
}
|
||||
],
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "bc8c1825-000b-401f-90c8-e70547041fca",
|
||||
"name": "address",
|
||||
"displayName": "Host address",
|
||||
"type": "QString",
|
||||
"inputType": "IPv4Address",
|
||||
"defaultValue": ""
|
||||
},
|
||||
{
|
||||
"id": "5882b8a7-2c83-4601-9dd4-e4cc5b5baed7",
|
||||
"name": "hostName",
|
||||
"displayName": "Host name",
|
||||
"type": "QString",
|
||||
"inputType": "TextLine",
|
||||
"defaultValue": ""
|
||||
},
|
||||
{
|
||||
"id": "9f16596f-1695-4112-bdca-df7207b4e30b",
|
||||
"name":"macAddress",
|
||||
"displayName": "MAC address",
|
||||
"type": "QString",
|
||||
"inputType": "MacAddress",
|
||||
"defaultValue": ""
|
||||
},
|
||||
{
|
||||
"id": "70ea61e2-18c3-4f82-a66c-6ed6876d3567",
|
||||
"name":"port",
|
||||
"displayName": "Port",
|
||||
"type": "int",
|
||||
"defaultValue": 502
|
||||
},
|
||||
{
|
||||
"id": "e8876609-cfad-4ebd-b998-d0b1b2dc5434",
|
||||
"name":"slaveId",
|
||||
"displayName": "Slave ID",
|
||||
"type": "int",
|
||||
"readOnly": true,
|
||||
"defaultValue": 1
|
||||
},
|
||||
{
|
||||
"id": "ad2ad84f-8513-4337-b7cf-fa45a9914a15",
|
||||
"name":"meterSlaveId",
|
||||
"displayName": "Smart Meter slave ID",
|
||||
"type": "int",
|
||||
"minValue": 0,
|
||||
"maxValue": 247,
|
||||
"defaultValue": 200
|
||||
}
|
||||
],
|
||||
"stateTypes":[
|
||||
{
|
||||
"id": "d4cf07e1-6b53-47b6-9cab-7c7764023453",
|
||||
"name": "connected",
|
||||
"displayName": "Connected",
|
||||
"displayNameEvent": "Connected changed",
|
||||
"type": "bool",
|
||||
"defaultValue": false,
|
||||
"cached": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ include(../plugins.pri)
|
||||
include(../sunspec.pri)
|
||||
|
||||
SOURCES += \
|
||||
froniusrules.cpp \
|
||||
integrationpluginsunspec.cpp \
|
||||
solaredgebattery.cpp \
|
||||
sunspecdiscovery.cpp \
|
||||
@ -9,6 +10,7 @@ SOURCES += \
|
||||
sunspecthing.cpp
|
||||
|
||||
HEADERS += \
|
||||
froniusrules.h \
|
||||
integrationpluginsunspec.h \
|
||||
solaredgebattery.h \
|
||||
sunspecdiscovery.h \
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user