Patrick Schurig 89406495a8 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.
2026-07-31 10:33:12 +02:00

173 lines
5.3 KiB
C++

// 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;
}
}