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:
Patrick Schurig 2026-07-31 10:33:12 +02:00
parent c5c3f46f57
commit 89406495a8
6 changed files with 575 additions and 6 deletions

172
sunspec/froniusrules.cpp Normal file
View 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 &registerName)
{
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
View 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 &registerName);
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

View File

@ -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);

View File

@ -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();

View File

@ -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
}
]
}
]
}
]
}

View File

@ -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 \