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 \