diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp
new file mode 100644
index 0000000..d655b91
--- /dev/null
+++ b/sunspec/froniusrules.cpp
@@ -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 .
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+#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 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 &readings)
+{
+ double total = 0;
+ foreach (const ModuleReading &reading, readings) {
+ if (classifyBlock(reading.inputIdString) == BlockRolePvString)
+ total += reading.dcPower;
+ }
+ return total;
+}
+
+double batteryPower(const QList &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;
+}
+
+}
diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h
new file mode 100644
index 0000000..ced9824
--- /dev/null
+++ b/sunspec/froniusrules.h
@@ -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 .
+*
+* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+
+#ifndef FRONIUSRULES_H
+#define FRONIUSRULES_H
+
+#include
+#include
+#include
+
+#include
+
+#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 &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 &readings);
+
+}
+
+#endif // FRONIUSRULES_H
diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp
index 943e1d1..133cedd 100644
--- a/sunspec/integrationpluginsunspec.cpp
+++ b/sunspec/integrationpluginsunspec.cpp
@@ -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 &continuation)
+{
+ QObject *context = new QObject(info);
+ QSharedPointer fired = QSharedPointer::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);
diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h
index 968832c..85bcd05 100644
--- a/sunspec/integrationpluginsunspec.h
+++ b/sunspec/integrationpluginsunspec.h
@@ -34,6 +34,7 @@
#include "sunspecthing.h"
#include "sunspecstoragelogic.h"
+#include "froniusrules.h"
#include "extern-plugininfo.h"
#include
@@ -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 &continuation);
+ void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan);
private slots:
void onRefreshTimer();
diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json
index 3fb302d..30ec4c6 100644
--- a/sunspec/integrationpluginsunspec.json
+++ b/sunspec/integrationpluginsunspec.json
@@ -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
+ }
+ ]
+ }
+ ]
}
]
}
diff --git a/sunspec/sunspec.pro b/sunspec/sunspec.pro
index 6496657..379eccd 100644
--- a/sunspec/sunspec.pro
+++ b/sunspec/sunspec.pro
@@ -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 \