byteOrder() renvoyait ByteOrderLittleEndian au motif qu'on « supposait le défaut SunSpec ». La supposition confondait le défaut de la SPÉCIFICATION avec celui de libnymea-sunspec, qui est son contraire : convertToUInt32 lit registers.at(1) puis .at(0) en little endian. Garder ce défaut revient à lire tous les float32 avec leurs deux mots permutés. Mesuré le 2026-08-07 sur un Symo réel, en lecture seule : le compteur 213 sur l'unité 200 sert Hz = 4248 0000, soit 50,0000 en big endian et 2,3777e-41 en mots inversés. L'onduleur 113 concorde. Le coût n'était pas un décalage mais une absurdité. Reproduit sur banc avec le simulateur émettant l'ordre du matériel : un compteur important 2,7 kW rapporté comme exportant 107 MW, et l'arbitre enclenchant toutes les charges sur un « Surplus PV 107622496 W ». Le thing reste connected, sans erreur Modbus. Conséquence sur l'ordre des correctifs : meterPowerSign() reste juste mais n'est observable qu'une fois celui-ci appliqué — corriger le signe d'un dénormal ne produit rien. solarEdge force déjà big endian de son côté et le générique garde son paramètre utilisateur : aucun des deux chemins n'est touché. test_AC_18 fige le raisonnement plutôt que la seule valeur. Voir DIVERGENCES.md D-8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
6.6 KiB
C++
207 lines
6.6 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;
|
|
}
|
|
|
|
double meterPowerSign(bool froniusMeter)
|
|
{
|
|
/* The Fronius meter is already in the nymea convention, so the generic
|
|
* negation has to be undone for it, and only for it. */
|
|
return froniusMeter ? 1.0 : -1.0;
|
|
}
|
|
|
|
bool hasBattery(double batteryVoltage, double availableCharge)
|
|
{
|
|
/* Strictly positive only: zero is the no-battery reading, and a negative
|
|
* value is the library's way of surfacing a not-implemented point rather
|
|
* than a real measurement. */
|
|
return batteryVoltage > 0.0 || availableCharge > 0.0;
|
|
}
|
|
|
|
SunSpecDataPoint::ByteOrder byteOrder()
|
|
{
|
|
/* Big endian is what the hardware emits, and what SunSpec specifies. The
|
|
* library's default is the opposite; see the header for the measurement. */
|
|
return SunSpecDataPoint::ByteOrderBigEndian;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
}
|