Patrick Schurig 51a211e080 fix(sunspec): rendre la batterie SunSpec visible au moteur (energystorage)
sunspecStorage déclarait `battery` mais pas `energystorage`. Les trois
consommateurs qui filtrent par cette interface ne la trouvaient donc pas : le
bilan de puissance de nymea (`currentPowerStorage`, celui que le CLIENT lit sur
son tableau de bord), la réserve de stockage de la recharge VE, et celle du
waterfall. Vérifié sans authentification dans `energylogs.sqlite` de .75 : la
colonne `storage` de `powerBalance` est à 0.0 sur toutes les lignes, pendant
que la batterie affiche 50 % et « Holding ». Elle n'était pas à zéro, elle
était invisible.

Ce n'était pas une ligne de JSON. `energystorage` exige `currentPower` (W,
signé) et `capacity` (kWh), qu'aucun des quatorze états ne portait, et les
déclarer sans les câbler aurait donné une batterie éternellement inerte — pire
que son absence, puisque la réserve lirait « ni charge ni décharge » au lieu de
« je ne sais pas ».

D'où viennent les deux états. Le modèle 124 ne porte AUCUNE puissance : WChaMax
est un maximum, StorAval est en ampères-heures. La puissance instantanée vit
dans le modèle 160, réparti sur deux blocs `StCha` et `StDisCha` dont le `DCW`
est un uint16 toujours positif, l'inactif à zéro. D'où
`currentPower = DCW[StCha] - DCW[StDisCha]`, positif en charge, conformément à
libnymea/interfaces/energystorage.json (« positive is 'consumed' »). C'est
l'INVERSE de la convention constructeur du contrat §4, et
`FroniusRules::batteryPower()` est réécrite comme la négation stricte de la
nouvelle `storageCurrentPower()` pour qu'aucune des deux ne dérive seule.
`capacity` vient de WHRtg du modèle 120, et l'état est laissé tranquille plutôt
que mis à zéro quand le nameplate ne l'implémente pas.

Le signe est MESURÉ, pas déduit. Banc .211 unité 1, consigne batterie imposée
par MQTT, registres relus en lecture seule :
  +2000 W -> StCha 2000,0 W  StDisCha    0,0 W  ChaSt Charging
  -1500 W -> StCha    0,0 W  StDisCha 1500,0 W  ChaSt Discharging
Le même relevé a montré `DCW_SF` passant de -2 à -1 entre repos et charge : le
facteur d'échelle n'est jamais mis en cache d'un relevé à l'autre.

Écart assumé, D-9 dans DIVERGENCES.md : `sunspecStorage` est partagé avec le
vendor générique, et les interfaces se déclarent par thingClass. Un appareil
générique publiant le 124 sans le 160 porte désormais `energystorage` avec un
`currentPower` figé à zéro. Les trois alternatives sont pires — une thingClass
Fronius distincte détruirait le Thing existant et son historique. Le plugin
avertit donc UNE FOIS par thing quand il ne trouve pas de source de puissance,
en distinguant « pas de modèle 160 » de « modèle 160 sans bloc batterie ». Ça ne
remplace pas la mesure, mais ça retire le silence.

La cohérence des unités avec l'interface est vérifiée à la COMPILATION par
nymea-plugininfocompiler : mettre `KiloWattHour` sur `currentPower` fait échouer
le build. Onze tests `test_D9_*` figent le reste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3gzsuwENJfJtvuNDT3xGn
2026-08-26 14:02:37 +02:00

225 lines
7.2 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 &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 storageCurrentPower(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 charging - discharging;
}
double batteryPower(const QList<ModuleReading> &readings)
{
/* Written as the negation rather than as a second sum: two independent
* implementations of the same subtraction are two places for the sign to
* rot, and only one of them has a test watching it. */
return -storageCurrentPower(readings);
}
bool hasBatteryBlocks(const QList<ModuleReading> &readings)
{
foreach (const ModuleReading &reading, readings) {
const BlockRole role = classifyBlock(reading.inputIdString);
if (role == BlockRoleBatteryCharge || role == BlockRoleBatteryDischarge)
return true;
}
return false;
}
}