A GEN24 answers for the inverter on one unit ID and for the Smart Meter on another, behind the same host and port. Isolate the decision to open that second connection as a pure function, so it can be pinned down without nymea, and leave the logging of the refusal to the caller. Refuses a meter slave ID out of the 0..247 range, treats 0 as "no meter on this connection" as documented, and refuses a meter slave ID equal to the inverter one: a second socket on the same unit ID would rediscover the very same models and duplicate every child thing. That last case is reachable, since the discovery writes the scanned slave ID into the thing parameter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
190 lines
5.9 KiB
C++
190 lines
5.9 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;
|
|
}
|
|
|
|
MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId)
|
|
{
|
|
if (meterSlaveId == 0)
|
|
return MeterConnectionDisabled;
|
|
|
|
if (meterSlaveId < 0 || meterSlaveId > MaximumSlaveId)
|
|
return MeterConnectionInvalidSlaveId;
|
|
|
|
/* Not a theoretical case: the slave ID of the thing is written by the
|
|
* discovery from the slave ID it was asked to scan, so an inverter found
|
|
* with a discovery slave ID of 200 ends up with both values at 200. */
|
|
if (meterSlaveId == inverterSlaveId)
|
|
return MeterConnectionCollidesWithInverter;
|
|
|
|
return MeterConnectionCreate;
|
|
}
|
|
|
|
int clampUpdateInterval(int requestedSeconds)
|
|
{
|
|
return qMax(requestedSeconds, MinimumUpdateIntervalSeconds);
|
|
}
|
|
|
|
bool isWriteForbidden(const QString ®isterName)
|
|
{
|
|
static const QStringList forbidden = {
|
|
QStringLiteral("WChaMax"),
|
|
QStringLiteral("WChaGra"),
|
|
QStringLiteral("WDisChaGra"),
|
|
QStringLiteral("InOutWRte_WinTms"),
|
|
QStringLiteral("InOutWRte_RmpTms"),
|
|
/* VAChaMax is doubly unusable: the write is ignored and its scale
|
|
* factor is not supported either. */
|
|
QStringLiteral("VAChaMax")
|
|
};
|
|
|
|
foreach (const QString &name, forbidden) {
|
|
if (registerName.compare(name, Qt::CaseInsensitive) == 0)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
SetpointPlan planBatteryMode(BatteryMode mode, double chargeLimit, double dischargeLimit, quint16 revertTime)
|
|
{
|
|
/* StorCtl_Mod is a bitfield: bit 0 CHARGE, bit 1 DISCHARGE. ChaGriSet is
|
|
* 0 PV, 1 GRID. A register absent from the vector below is deliberately not
|
|
* written for that mode.
|
|
*
|
|
* planSetpoint() puts these in the mandated order and appends the revert
|
|
* timer, so the order they are listed in here does not matter. */
|
|
QVector<Write> setpoints;
|
|
|
|
switch (mode) {
|
|
case BatteryModeNormal:
|
|
setpoints.append({RegisterStorCtlMod, 0});
|
|
setpoints.append({RegisterOutWRte, 100});
|
|
break;
|
|
|
|
case BatteryModeNoDischarge:
|
|
setpoints.append({RegisterStorCtlMod, 2});
|
|
setpoints.append({RegisterOutWRte, 0});
|
|
break;
|
|
|
|
case BatteryModeNoCharge:
|
|
setpoints.append({RegisterStorCtlMod, 1});
|
|
setpoints.append({RegisterInWRte, 0});
|
|
break;
|
|
|
|
case BatteryModeFrozen:
|
|
setpoints.append({RegisterStorCtlMod, 3});
|
|
setpoints.append({RegisterInWRte, 0});
|
|
setpoints.append({RegisterOutWRte, 0});
|
|
break;
|
|
|
|
case BatteryModeForceCharge:
|
|
/* Charging from the grid has to be allowed on the inverter as well;
|
|
* ChaGriSet alone is ANDed with a setting of the component
|
|
* configuration and fails silently if that one is off. */
|
|
setpoints.append({RegisterChaGriSet, 1});
|
|
setpoints.append({RegisterStorCtlMod, 2});
|
|
/* Negative is charging. */
|
|
setpoints.append({RegisterOutWRte, -chargeLimit});
|
|
break;
|
|
|
|
case BatteryModeLimit:
|
|
setpoints.append({RegisterStorCtlMod, 3});
|
|
setpoints.append({RegisterInWRte, chargeLimit});
|
|
setpoints.append({RegisterOutWRte, dischargeLimit});
|
|
break;
|
|
}
|
|
|
|
return planSetpoint(setpoints, revertTime);
|
|
}
|
|
|
|
BlockRole classifyBlock(const QString &inputIdString)
|
|
{
|
|
const QString id = inputIdString.trimmed();
|
|
if (id.isEmpty())
|
|
return BlockRoleUnknown;
|
|
|
|
/* Prefix match, not contains: the role is decided by the string, never by
|
|
* the block index. StDisCha is checked first so that no cascade can ever
|
|
* swallow it into the charge case. */
|
|
if (id.startsWith(QStringLiteral("StDisCha"), Qt::CaseInsensitive))
|
|
return BlockRoleBatteryDischarge;
|
|
|
|
if (id.startsWith(QStringLiteral("StCha"), Qt::CaseInsensitive))
|
|
return BlockRoleBatteryCharge;
|
|
|
|
if (id.startsWith(QStringLiteral("MPPT"), Qt::CaseInsensitive))
|
|
return BlockRolePvString;
|
|
|
|
return BlockRoleUnknown;
|
|
}
|
|
|
|
double pvPower(const QList<ModuleReading> &readings)
|
|
{
|
|
double total = 0;
|
|
foreach (const ModuleReading &reading, readings) {
|
|
if (classifyBlock(reading.inputIdString) == BlockRolePvString)
|
|
total += reading.dcPower;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
double batteryPower(const QList<ModuleReading> &readings)
|
|
{
|
|
double charging = 0;
|
|
double discharging = 0;
|
|
|
|
foreach (const ModuleReading &reading, readings) {
|
|
switch (classifyBlock(reading.inputIdString)) {
|
|
case BlockRoleBatteryCharge:
|
|
charging += reading.dcPower;
|
|
break;
|
|
case BlockRoleBatteryDischarge:
|
|
discharging += reading.dcPower;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return discharging - charging;
|
|
}
|
|
|
|
}
|