Patrick Schurig 89f7bf0199 fix(sunspec): signe du compteur Fronius et batterie fantôme
Deux défauts constatés sur le GEN24 des beaux-parents (Primo GEN24 3.0,
Smart Meter TS 65A-3, sans batterie).

Signe du compteur. onMeterBlockUpdated() négociait le signe de tous les
modèles compteur sans distinction de fabricant. Sur Fronius la négation est
fausse : le Smart Meter émet déjà dans la convention nymea. L'onduleur étant
monophasé, il sert de référence de direction : le plugin rapportait la phase
portant l'onduleur en soutirage et les deux autres en injection, ce qui est
impossible sans source. L'arbitre en concluait « Surplus insuffisant
(-842 W) » et laissait chauffe-eau et PAC éteints en plein export.

La décision vit dans FroniusRules::meterPowerSign() et n'est annulée que sous
une froniusConnection. Les vendors generic et solarEdge sont inchangés, ce
qu'un antidote verrouille. Les compteurs d'énergie sont corrects et ne sont
pas permutés.

Batterie fantôme. Un GEN24 hybride publie le modèle 124 sans batterie câblée,
d'où un enfant storage bloqué sur batteryCritical.
FroniusRules::hasBattery() exige une grandeur physique non nulle (InBatV ou
StorAval), le SoC étant écarté car 0 % ne se distingue pas d'une batterie
vide. Le côté « batterie présente » reste INFÉRÉ : voir D-6, à valider sur un
GEN24 équipé avant tout déploiement.

Suite logique : 50 tests verts, dont six antidotes AC-16 et AC-17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 08:29:16 +02:00

952 lines
37 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
/* Tests des criteres d'acceptation de specs/fronius-sunspec.md.
*
* Chaque test porte le numero de l'AC qu'il valide : test_AC_<n>_<quoi>.
*
* Deux mecanismes :
*
* - les criteres qui portent sur la *definition* du plugin (plages de valeurs,
* thingClasses, valeurs par defaut) sont verifies en relisant
* integrationpluginsunspec.json ;
* - les criteres qui portent sur le *comportement* sont verifies soit sur un
* dump de registres passe au vrai modele de libnymea-sunspec, soit sur les
* fonctions pures de sunspecstoragelogic / froniusrules.
*
* Ce qui n'est pas verifiable ici (trames Modbus reellement emises, expiration
* du timer, auto-scale des SF en fonctionnement) est liste explicitement dans
* specs/fronius-sunspec.md.
*/
#include <QTest>
#include <QObject>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <models/sunspecstoragemodel.h>
#include "sunspecstoragelogic.h"
#include "froniusrules.h"
using namespace SunSpecStorageLogic;
/* Donne acces aux entrailles du modele pour lui injecter un dump de registres
* sans connexion Modbus. Reproduit ce que fait SunSpecModel::handleNewBlockData()
* apres une lecture reelle : remplir les data points depuis le bloc, puis
* appeler processBlockData(). */
class StorageModelFixture : public SunSpecStorageModel
{
public:
explicit StorageModelFixture(const QVector<quint16> &blockData)
: SunSpecStorageModel(nullptr, 40000, 24, SunSpecDataPoint::ByteOrderLittleEndian)
{
m_blockData = blockData;
foreach (const QString &name, m_dataPoints.keys()) {
m_dataPoints[name].setRawData(m_blockData.mid(m_dataPoints[name].addressOffset(),
m_dataPoints[name].size()));
}
processBlockData();
}
};
/* Dump du modele 124, L = 24 : ID + L + 24 registres. Les offsets sont ceux du
* modele genere (ID a 0), pas des adresses absolues : aucune adresse en dur
* n'est introduite ici (INV-1). */
struct StorageBlock
{
quint16 wChaMax = 10000;
qint16 wChaGra = 300; // gradient, % WChaMax/s
qint16 wDisChaGra = 400; // gradient, % WChaMax/s
quint16 storCtlMod = 0; // bitfield : bit 0 charge, bit 1 decharge
quint16 vaChaMax = 0;
quint16 minRsvPct = 500;
quint16 chaState = 5000; // 50,00 % avec SF -2
quint16 storAval = 0xFFFF; // non supporte -> sentinelle
quint16 inBatV = 0xFFFF; // non supporte -> sentinelle
quint16 chaSt = 4; // CHARGING
qint16 outWRte = 1000; // 10,00 % avec SF -2
qint16 inWRte = -2500; // -25,00 % avec SF -2
quint16 winTms = 0;
quint16 rvrtTms = 0;
quint16 rmpTms = 0;
quint16 chaGriSet = 0; // 0 = PV, 1 = GRID
qint16 wChaMaxSf = 0;
qint16 wChaDisChaGraSf = -2;
qint16 vaChaMaxSf = 0;
qint16 minRsvPctSf = -2;
qint16 chaStateSf = -2;
qint16 storAvalSf = 0;
qint16 inBatVSf = 0;
qint16 inOutWRteSf = -2;
QVector<quint16> toRegisters() const
{
QVector<quint16> r(26, 0);
r[0] = 124; // ID
r[1] = 24; // L
r[2] = wChaMax;
r[3] = static_cast<quint16>(wChaGra);
r[4] = static_cast<quint16>(wDisChaGra);
r[5] = storCtlMod;
r[6] = vaChaMax;
r[7] = minRsvPct;
r[8] = chaState;
r[9] = storAval;
r[10] = inBatV;
r[11] = chaSt;
r[12] = static_cast<quint16>(outWRte);
r[13] = static_cast<quint16>(inWRte);
r[14] = winTms;
r[15] = rvrtTms;
r[16] = rmpTms;
r[17] = chaGriSet;
r[18] = static_cast<quint16>(wChaMaxSf);
r[19] = static_cast<quint16>(wChaDisChaGraSf);
r[20] = static_cast<quint16>(vaChaMaxSf);
r[21] = static_cast<quint16>(minRsvPctSf);
r[22] = static_cast<quint16>(chaStateSf);
r[23] = static_cast<quint16>(storAvalSf);
r[24] = static_cast<quint16>(inBatVSf);
r[25] = static_cast<quint16>(inOutWRteSf);
return r;
}
};
class SunSpecFroniusTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
// Bloc 0 - correctifs upstream
void test_AC_1_charging_rate_range_allows_forced_charge();
void test_AC_1_discharging_rate_range_allows_negative();
void test_AC_2_charging_rate_is_read_from_in_w_rte();
void test_AC_2_discharging_rate_is_read_from_out_w_rte();
void test_AC_2_gradients_exposed_under_distinct_names();
void test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod();
void test_AC_3_enable_charging_still_reconciled();
void test_AC_4_setpoint_plan_writes_revert_time_last();
void test_AC_4_revert_time_is_written_even_for_a_single_setpoint();
void test_AC_4_generic_vendor_defaults_revert_time_to_zero();
// Bloc 1 - vendor fronius
void test_AC_5_manufacturer_filter_matches_fronius_case_insensitively();
void test_AC_5_manufacturer_filter_rejects_other_manufacturers();
void test_AC_6_fronius_uses_sunspec_default_byte_order();
void test_AC_7_fronius_connection_declares_expected_parameters();
void test_AC_7_fronius_connection_does_not_expose_endianness();
void test_AC_8_update_interval_floor_is_seven_seconds();
void test_AC_8_update_interval_above_floor_is_untouched();
void test_AC_8_fronius_polling_defaults();
void test_AC_9_forbidden_registers_are_refused();
void test_AC_9_allowed_registers_are_not_refused();
void test_AC_10_fronius_revert_time_default_is_300_seconds();
// Bloc 2 - pilotage batterie
void test_AC_11_mode_produces_contract_registers_data();
void test_AC_11_mode_produces_contract_registers();
void test_AC_11_write_order_follows_contract();
void test_AC_12_plan_rereads_scale_factor_before_writing();
void test_AC_13_plan_verifies_written_registers_by_readback();
// Bloc 3 - modele 160
void test_AC_14_blocks_are_classified_by_id_string();
void test_AC_14_st_dis_cha_is_not_confused_with_st_cha();
void test_AC_14_unknown_id_string_is_not_classified();
void test_AC_15_pv_power_sums_mppt_blocks_only();
void test_AC_15_battery_power_is_positive_when_discharging();
void test_AC_15_battery_power_is_negative_when_charging();
void test_AC_16_fronius_meter_keeps_the_device_sign();
void test_AC_16_generic_meter_keeps_the_historical_negation();
void test_AC_16_field_case_export_is_reported_negative();
void test_AC_17_no_battery_means_no_storage_child();
void test_AC_17_an_installed_battery_is_still_detected();
void test_AC_17_presence_does_not_depend_on_state_of_charge();
// F3 - multi unit ID
void test_F3_meter_slave_id_zero_disables_the_meter_connection();
void test_F3_meter_slave_id_out_of_range_is_refused();
void test_F3_meter_slave_id_equal_to_the_inverter_is_refused();
void test_F3_meter_slave_id_creates_the_connection();
void test_F3_meter_slave_id_is_not_marked_advanced();
void test_F3_generic_vendors_have_no_meter_slave_id();
private:
QJsonObject m_pluginJson;
QJsonObject thingClassByName(const QString &name) const;
QJsonObject stateByName(const QJsonObject &thingClass, const QString &name) const;
QJsonObject paramByName(const QJsonObject &thingClass, const QString &name) const;
static QJsonObject findByName(const QJsonArray &array, const QString &name);
};
void SunSpecFroniusTest::initTestCase()
{
QFile file(PLUGIN_JSON_PATH);
QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
m_pluginJson = document.object();
QVERIFY(!m_pluginJson.isEmpty());
}
QJsonObject SunSpecFroniusTest::findByName(const QJsonArray &array, const QString &name)
{
foreach (const QJsonValue &value, array) {
if (value.toObject().value("name").toString() == name)
return value.toObject();
}
return QJsonObject();
}
/* Cherche une thingClass a la racine puis dans chaque vendor. */
QJsonObject SunSpecFroniusTest::thingClassByName(const QString &name) const
{
const QJsonObject direct = findByName(m_pluginJson.value("thingClasses").toArray(), name);
if (!direct.isEmpty())
return direct;
foreach (const QJsonValue &vendor, m_pluginJson.value("vendors").toArray()) {
const QJsonObject found = findByName(vendor.toObject().value("thingClasses").toArray(), name);
if (!found.isEmpty())
return found;
}
return QJsonObject();
}
QJsonObject SunSpecFroniusTest::stateByName(const QJsonObject &thingClass, const QString &name) const
{
return findByName(thingClass.value("stateTypes").toArray(), name);
}
QJsonObject SunSpecFroniusTest::paramByName(const QJsonObject &thingClass, const QString &name) const
{
return findByName(thingClass.value("paramTypes").toArray(), name);
}
// ---------------------------------------------------------------------------
// AC-1 - plage des consignes
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_1_charging_rate_range_allows_forced_charge()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
const QJsonObject state = stateByName(storage, "chargingRate");
QVERIFY(!state.isEmpty());
// La plage reelle de InWRte est -100,00 .. +100,00. Bornee a 0, une consigne
// de charge forcee est rejetee par nymea avant d'atteindre le plugin.
QCOMPARE(state.value("minValue").toDouble(), -100.0);
QCOMPARE(state.value("maxValue").toDouble(), 100.0);
}
void SunSpecFroniusTest::test_AC_1_discharging_rate_range_allows_negative()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
const QJsonObject state = stateByName(storage, "dischargingRate");
QVERIFY(!state.isEmpty());
QCOMPARE(state.value("minValue").toDouble(), -100.0);
QCOMPARE(state.value("maxValue").toDouble(), 100.0);
}
// ---------------------------------------------------------------------------
// AC-2 - coherence lecture/ecriture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_2_charging_rate_is_read_from_in_w_rte()
{
StorageBlock block;
block.inWRte = -2500; // -25,00 % avec SF -2
block.wChaGra = 300; // gradient : valeur volontairement differente
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
// La grandeur ecrite par l'action chargingRate est InWRte. Le state relu
// doit etre la meme grandeur, pas le gradient WChaGra.
QCOMPARE(states.chargingRate, -25.0);
QVERIFY(states.chargingRate != states.chargeGradient);
}
void SunSpecFroniusTest::test_AC_2_discharging_rate_is_read_from_out_w_rte()
{
StorageBlock block;
block.outWRte = 1000; // 10,00 %
block.wDisChaGra = 400; // gradient different
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
QCOMPARE(states.dischargingRate, 10.0);
QVERIFY(states.dischargingRate != states.dischargeGradient);
}
void SunSpecFroniusTest::test_AC_2_gradients_exposed_under_distinct_names()
{
StorageBlock block;
block.wChaGra = 300; // 3,00 % WChaMax/s avec SF -2
block.wDisChaGra = 400; // 4,00 % WChaMax/s
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
QCOMPARE(states.chargeGradient, 3.0);
QCOMPARE(states.dischargeGradient, 4.0);
// Les gradients restent exposes, mais sous des noms qui ne se confondent
// pas avec les consignes, et en lecture seule.
const QJsonObject storage = thingClassByName("sunspecStorage");
const QJsonObject chargeGradient = stateByName(storage, "chargeGradient");
const QJsonObject dischargeGradient = stateByName(storage, "dischargeGradient");
QVERIFY(!chargeGradient.isEmpty());
QVERIFY(!dischargeGradient.isEmpty());
QVERIFY(!chargeGradient.value("writable").toBool());
QVERIFY(!dischargeGradient.value("writable").toBool());
}
// ---------------------------------------------------------------------------
// AC-3 - reconciliation de enableDischarging
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod()
{
StorageBlock discharging;
discharging.storCtlMod = 0x2; // bit 1 = DISCHARGE
StorageModelFixture dischargingModel(discharging.toRegisters());
QCOMPARE(readStates(&dischargingModel).enableDischarging, true);
StorageBlock notDischarging;
notDischarging.storCtlMod = 0x1; // seulement CHARGE
StorageModelFixture notDischargingModel(notDischarging.toRegisters());
QCOMPARE(readStates(&notDischargingModel).enableDischarging, false);
}
void SunSpecFroniusTest::test_AC_3_enable_charging_still_reconciled()
{
// Garde-fou : la correction de enableDischarging ne doit pas casser
// enableCharging, qui etait deja correct en amont.
StorageBlock block;
block.storCtlMod = 0x1;
StorageModelFixture model(block.toRegisters());
const StorageStates states = readStates(&model);
QCOMPARE(states.enableCharging, true);
QCOMPARE(states.enableDischarging, false);
}
// ---------------------------------------------------------------------------
// AC-4 - ecriture de InOutWRte_RvrtTms
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_4_setpoint_plan_writes_revert_time_last()
{
const SetpointPlan plan = planSetpoint({{RegisterInWRte, 20.0},
{RegisterStorCtlMod, 3.0}}, 300);
QVERIFY(!plan.writes.isEmpty());
QCOMPARE(plan.writes.last().reg, RegisterRvrtTms);
QCOMPARE(plan.writes.last().value, 300.0);
// RvrtTms n'apparait qu'une fois, et en derniere position seulement.
int revertCount = 0;
foreach (const Write &write, plan.writes) {
if (write.reg == RegisterRvrtTms)
revertCount++;
}
QCOMPARE(revertCount, 1);
}
void SunSpecFroniusTest::test_AC_4_revert_time_is_written_even_for_a_single_setpoint()
{
// Une consigne isolee doit elle aussi armer le timer : c'est justement le
// cas ou une valeur persistante apres un arret du HEMS immobilise la
// batterie.
const SetpointPlan plan = planSetpoint({{RegisterOutWRte, 0.0}}, 300);
QCOMPARE(plan.writes.size(), 2);
QCOMPARE(plan.writes.at(0).reg, RegisterOutWRte);
QCOMPARE(plan.writes.at(1).reg, RegisterRvrtTms);
}
void SunSpecFroniusTest::test_AC_4_generic_vendor_defaults_revert_time_to_zero()
{
// Le vendor generique ne change pas de comportement par defaut : c'est un
// correctif proposable en amont, pas un changement de produit.
const QJsonObject storage = thingClassByName("sunspecStorage");
const QJsonObject param = paramByName(storage, "revertTime");
QVERIFY2(!param.isEmpty(), "sunspecStorage doit exposer un parametre revertTime");
QCOMPARE(param.value("defaultValue").toInt(), 0);
QCOMPARE(param.value("minValue").toInt(), 0);
QCOMPARE(param.value("maxValue").toInt(), 28800);
}
// ---------------------------------------------------------------------------
// AC-5 - filtre fabricant
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_5_manufacturer_filter_matches_fronius_case_insensitively()
{
QVERIFY(FroniusRules::matchesManufacturer({"Fronius"}));
QVERIFY(FroniusRules::matchesManufacturer({"fronius"}));
QVERIFY(FroniusRules::matchesManufacturer({"FRONIUS International GmbH"}));
// Le fabricant peut n'apparaitre que sur l'un des modeles Common.
QVERIFY(FroniusRules::matchesManufacturer({"", "Fronius"}));
}
void SunSpecFroniusTest::test_AC_5_manufacturer_filter_rejects_other_manufacturers()
{
QVERIFY(!FroniusRules::matchesManufacturer({"SolarEdge"}));
QVERIFY(!FroniusRules::matchesManufacturer({"Kostal"}));
QVERIFY(!FroniusRules::matchesManufacturer({}));
QVERIFY(!FroniusRules::matchesManufacturer({""}));
}
// ---------------------------------------------------------------------------
// AC-6 - ordre des octets
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order()
{
// INFERE : non documente par le constructeur, on suppose le defaut SunSpec.
// Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a
// SolarEdge et ne doit pas etre etendue.
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian);
}
// ---------------------------------------------------------------------------
// AC-7 - parametres de froniusConnection
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_7_fronius_connection_declares_expected_parameters()
{
const QJsonObject connection = thingClassByName("froniusConnection");
QVERIFY2(!connection.isEmpty(), "la thingClass froniusConnection doit exister");
foreach (const QString &name, QStringList({"address", "hostName", "macAddress", "port", "slaveId", "meterSlaveId"})) {
QVERIFY2(!paramByName(connection, name).isEmpty(), qPrintable("parametre manquant : " + name));
}
QCOMPARE(paramByName(connection, "port").value("defaultValue").toInt(), 502);
// Fixe en TCP sur GEN24.
QCOMPARE(paramByName(connection, "slaveId").value("defaultValue").toInt(), 1);
// 0 = pas de compteur sur cette connexion.
const QJsonObject meterSlaveId = paramByName(connection, "meterSlaveId");
QCOMPARE(meterSlaveId.value("defaultValue").toInt(), 200);
QCOMPARE(meterSlaveId.value("minValue").toInt(), 0);
QCOMPARE(meterSlaveId.value("maxValue").toInt(), 247);
}
void SunSpecFroniusTest::test_AC_7_fronius_connection_does_not_expose_endianness()
{
const QJsonObject connection = thingClassByName("froniusConnection");
QVERIFY(paramByName(connection, "endianness").isEmpty());
}
// ---------------------------------------------------------------------------
// AC-8 - plancher de cadence d'interrogation
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_8_update_interval_floor_is_seven_seconds()
{
// Descendre sous le plancher peut faire passer l'onduleur du client hors
// ligne, avec retablissement seulement par coupure AC+DC sur site. C'est un
// garde-fou dans le code, pas une valeur par defaut modifiable.
QCOMPARE(FroniusRules::clampUpdateInterval(1), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(6), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(0), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(-5), 7);
}
void SunSpecFroniusTest::test_AC_8_update_interval_above_floor_is_untouched()
{
QCOMPARE(FroniusRules::clampUpdateInterval(7), 7);
QCOMPARE(FroniusRules::clampUpdateInterval(10), 10);
QCOMPARE(FroniusRules::clampUpdateInterval(60), 60);
}
void SunSpecFroniusTest::test_AC_8_fronius_polling_defaults()
{
const QJsonObject connection = thingClassByName("froniusConnection");
QVERIFY(!connection.isEmpty());
QCOMPARE(FroniusRules::DefaultUpdateIntervalSeconds, 10);
QCOMPARE(FroniusRules::DefaultTimeoutMilliSeconds, 2000);
QCOMPARE(FroniusRules::DefaultNumberOfRetries, 3);
const QJsonObject updateInterval = paramByName(connection, "updateInterval");
if (!updateInterval.isEmpty()) {
QCOMPARE(updateInterval.value("defaultValue").toInt(), 10);
QCOMPARE(updateInterval.value("minValue").toInt(), 7);
}
}
// ---------------------------------------------------------------------------
// AC-9 - registres interdits en ecriture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_9_forbidden_registers_are_refused()
{
// Ces ecritures sont acceptees puis ignorees sans code d'exception Modbus :
// l'echec est invisible cote HEMS. On refuse donc avant emission.
foreach (const QString &name, QStringList({"WChaMax", "WChaGra", "WDisChaGra",
"InOutWRte_WinTms", "InOutWRte_RmpTms",
"VAChaMax"})) {
QVERIFY2(FroniusRules::isWriteForbidden(name), qPrintable("doit etre refuse : " + name));
}
}
void SunSpecFroniusTest::test_AC_9_allowed_registers_are_not_refused()
{
foreach (const QString &name, QStringList({"StorCtl_Mod", "InWRte", "OutWRte",
"InOutWRte_RvrtTms", "ChaGriSet", "MinRsvPct"})) {
QVERIFY2(!FroniusRules::isWriteForbidden(name), qPrintable("ne doit pas etre refuse : " + name));
}
}
// ---------------------------------------------------------------------------
// AC-10 - revertTime a 300 s sur fronius
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_10_fronius_revert_time_default_is_300_seconds()
{
// HYPOTHESE DE TRAVAIL (D4 non validee) : une consigne persistante apres un
// crash du HEMS immobilise la batterie du client. On arme donc le timer.
QCOMPARE(FroniusRules::DefaultRevertTimeSeconds, 300);
// Plage du registre.
QVERIFY(FroniusRules::DefaultRevertTimeSeconds > 0);
QVERIFY(FroniusRules::DefaultRevertTimeSeconds <= 28800);
}
// ---------------------------------------------------------------------------
// AC-11 - table intention HEMS -> registres
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_11_mode_produces_contract_registers_data()
{
QTest::addColumn<int>("mode");
QTest::addColumn<double>("chargeLimit");
QTest::addColumn<double>("dischargeLimit");
QTest::addColumn<int>("chaGriSet"); // -1 = registre non ecrit
QTest::addColumn<int>("storCtlMod");
QTest::addColumn<double>("inWRte"); // NaN = registre non ecrit
QTest::addColumn<double>("outWRte");
const double none = qQNaN();
QTest::newRow("NORMAL")
<< int(FroniusRules::BatteryModeNormal) << 0.0 << 0.0
<< -1 << 0 << none << 100.0;
QTest::newRow("NO_DISCHARGE")
<< int(FroniusRules::BatteryModeNoDischarge) << 0.0 << 0.0
<< -1 << 2 << none << 0.0;
QTest::newRow("NO_CHARGE")
<< int(FroniusRules::BatteryModeNoCharge) << 0.0 << 0.0
<< -1 << 1 << 0.0 << none;
QTest::newRow("FROZEN")
<< int(FroniusRules::BatteryModeFrozen) << 0.0 << 0.0
<< -1 << 3 << 0.0 << 0.0;
// Convention de signe du constructeur : negatif = charge.
QTest::newRow("FORCE_CHARGE_60")
<< int(FroniusRules::BatteryModeForceCharge) << 60.0 << 0.0
<< 1 << 2 << none << -60.0;
QTest::newRow("LIMIT_40_70")
<< int(FroniusRules::BatteryModeLimit) << 40.0 << 70.0
<< -1 << 3 << 40.0 << 70.0;
}
void SunSpecFroniusTest::test_AC_11_mode_produces_contract_registers()
{
QFETCH(int, mode);
QFETCH(double, chargeLimit);
QFETCH(double, dischargeLimit);
QFETCH(int, chaGriSet);
QFETCH(int, storCtlMod);
QFETCH(double, inWRte);
QFETCH(double, outWRte);
const SetpointPlan plan = FroniusRules::planBatteryMode(
static_cast<FroniusRules::BatteryMode>(mode), chargeLimit, dischargeLimit,
FroniusRules::DefaultRevertTimeSeconds);
auto valueOf = [&plan](Register reg, bool *found) {
*found = false;
double value = 0;
foreach (const Write &write, plan.writes) {
if (write.reg == reg) {
*found = true;
value = write.value;
}
}
return value;
};
bool found = false;
double value = 0;
value = valueOf(RegisterChaGriSet, &found);
if (chaGriSet < 0) {
QVERIFY2(!found, "ChaGriSet ne doit pas etre ecrit pour ce mode");
} else {
QVERIFY(found);
QCOMPARE(int(value), chaGriSet);
}
value = valueOf(RegisterStorCtlMod, &found);
QVERIFY2(found, "StorCtl_Mod est ecrit pour tous les modes");
QCOMPARE(int(value), storCtlMod);
value = valueOf(RegisterInWRte, &found);
if (qIsNaN(inWRte)) {
QVERIFY2(!found, "InWRte ne doit pas etre ecrit pour ce mode");
} else {
QVERIFY(found);
QCOMPARE(value, inWRte);
}
value = valueOf(RegisterOutWRte, &found);
if (qIsNaN(outWRte)) {
QVERIFY2(!found, "OutWRte ne doit pas etre ecrit pour ce mode");
} else {
QVERIFY(found);
QCOMPARE(value, outWRte);
}
// RvrtTms est arme pour tous les modes.
value = valueOf(RegisterRvrtTms, &found);
QVERIFY(found);
QCOMPARE(int(value), 300);
}
void SunSpecFroniusTest::test_AC_11_write_order_follows_contract()
{
// Ordre impose : ChaGriSet -> StorCtl_Mod -> InWRte / OutWRte -> RvrtTms.
const SetpointPlan plan = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeLimit, 40.0, 70.0, 300);
QVector<int> ranks;
foreach (const Write &write, plan.writes) {
switch (write.reg) {
case RegisterChaGriSet: ranks.append(0); break;
case RegisterStorCtlMod: ranks.append(1); break;
case RegisterInWRte:
case RegisterOutWRte: ranks.append(2); break;
case RegisterRvrtTms: ranks.append(3); break;
}
}
QVERIFY(!ranks.isEmpty());
for (int i = 1; i < ranks.size(); i++) {
QVERIFY2(ranks.at(i) >= ranks.at(i - 1), "ordre d'ecriture non respecte");
}
QCOMPARE(ranks.last(), 3);
// Le mode FORCE_CHARGE ecrit ChaGriSet : il doit venir en premier.
const SetpointPlan forceCharge = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeForceCharge, 60.0, 0.0, 300);
QCOMPARE(forceCharge.writes.first().reg, RegisterChaGriSet);
}
// ---------------------------------------------------------------------------
// AC-12 - relecture du scale factor avant ecriture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_12_plan_rereads_scale_factor_before_writing()
{
// Les SF changent avec le firmware et en fonctionnement (auto-scale). Un SF
// mis en cache lors d'un cycle anterieur produit une consigne d'un facteur
// 10 a cote.
const SetpointPlan withRate = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeLimit, 40.0, 70.0, 300);
QVERIFY2(withRate.rereadScaleFactor,
"toute ecriture de InWRte/OutWRte doit etre precedee d'une relecture de InOutWRte_SF");
const SetpointPlan generic = planSetpoint({{RegisterInWRte, 20.0}}, 300);
QVERIFY(generic.rereadScaleFactor);
// Un plan qui n'ecrit aucune grandeur mise a l'echelle n'a pas besoin de
// relire le SF.
const SetpointPlan noRate = planSetpoint({{RegisterStorCtlMod, 3.0}}, 300);
QVERIFY(!noRate.rereadScaleFactor);
}
// ---------------------------------------------------------------------------
// AC-13 - verification par relecture
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_13_plan_verifies_written_registers_by_readback()
{
// Seule parade aux echecs silencieux : pilotage Modbus non autorise, charge
// reseau non autorisee, restriction d'IP, assistant tiers qui ecrase.
const SetpointPlan plan = FroniusRules::planBatteryMode(
FroniusRules::BatteryModeForceCharge, 60.0, 0.0, 300);
QVERIFY(plan.verifyReadback.contains(RegisterStorCtlMod));
QVERIFY(plan.verifyReadback.contains(RegisterOutWRte));
QVERIFY(plan.verifyReadback.contains(RegisterChaGriSet));
// Tout registre ecrit, sauf RvrtTms, est relu.
foreach (const Write &write, plan.writes) {
if (write.reg == RegisterRvrtTms)
continue;
QVERIFY2(plan.verifyReadback.contains(write.reg),
"tout registre ecrit doit etre relu");
}
}
// ---------------------------------------------------------------------------
// AC-14 - classement des blocs du modele 160 par IDStr
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_14_blocks_are_classified_by_id_string()
{
using namespace FroniusRules;
QCOMPARE(classifyBlock("MPPT 1"), BlockRolePvString);
QCOMPARE(classifyBlock("MPPT 2"), BlockRolePvString);
QCOMPARE(classifyBlock("StCha 3"), BlockRoleBatteryCharge);
QCOMPARE(classifyBlock("StDisCha 4"), BlockRoleBatteryDischarge);
// Insensible a la casse et aux espaces de bord.
QCOMPARE(classifyBlock(" mppt 1 "), BlockRolePvString);
QCOMPARE(classifyBlock("STDISCHA 4"), BlockRoleBatteryDischarge);
// Sur un onduleur a 3 ou 4 MPPT, les blocs batterie ne sont pas en
// position 3 et 4 : c'est la chaine qui decide, jamais l'index.
QCOMPARE(classifyBlock("MPPT 3"), BlockRolePvString);
QCOMPARE(classifyBlock("MPPT 4"), BlockRolePvString);
QCOMPARE(classifyBlock("StCha 5"), BlockRoleBatteryCharge);
QCOMPARE(classifyBlock("StDisCha 6"), BlockRoleBatteryDischarge);
}
void SunSpecFroniusTest::test_AC_14_st_dis_cha_is_not_confused_with_st_cha()
{
// Piege : une implementation par contains() classerait StDisCha en StCha.
QCOMPARE(FroniusRules::classifyBlock("StDisCha 4"), FroniusRules::BlockRoleBatteryDischarge);
QVERIFY(FroniusRules::classifyBlock("StDisCha 4") != FroniusRules::BlockRoleBatteryCharge);
}
void SunSpecFroniusTest::test_AC_14_unknown_id_string_is_not_classified()
{
QCOMPARE(FroniusRules::classifyBlock(""), FroniusRules::BlockRoleUnknown);
QCOMPARE(FroniusRules::classifyBlock("Something else"), FroniusRules::BlockRoleUnknown);
}
// ---------------------------------------------------------------------------
// AC-15 - agregats du modele 160
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_15_pv_power_sums_mppt_blocks_only()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 1200.0},
{"MPPT 2", 800.0},
{"StCha 3", 500.0}, // batterie : ne compte pas dans le PV
{"StDisCha 4", 0.0},
{"Something else", 999.0} // non classe : ne compte nulle part
};
QCOMPARE(pvPower(readings), 2000.0);
}
void SunSpecFroniusTest::test_AC_15_battery_power_is_positive_when_discharging()
{
using namespace FroniusRules;
// DCW est un uint16, toujours positif : le signe est porte par le bloc
// actif, le bloc inactif etant a zero.
const QList<ModuleReading> readings = {
{"MPPT 1", 1200.0},
{"StCha 3", 0.0},
{"StDisCha 4", 750.0}
};
QCOMPARE(batteryPower(readings), 750.0);
}
void SunSpecFroniusTest::test_AC_15_battery_power_is_negative_when_charging()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 1200.0},
{"StCha 3", 600.0},
{"StDisCha 4", 0.0}
};
QCOMPARE(batteryPower(readings), -600.0);
}
// ---------------------------------------------------------------------------
// F3 - multi unit ID
//
// Le GEN24 repond pour l'onduleur sur un unit ID et pour le Smart Meter sur un
// autre, derriere le meme host:port. La decision d'ouvrir ou non la seconde
// connexion est isolee ici pour etre testable sans nymea ; le plugin se contente
// de journaliser le motif du refus.
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_F3_meter_slave_id_zero_disables_the_meter_connection()
{
using namespace FroniusRules;
// 0 est la facon documentee de dire "pas de compteur derriere cet onduleur".
QCOMPARE(decideMeterConnection(0, InverterSlaveId), MeterConnectionDisabled);
}
void SunSpecFroniusTest::test_F3_meter_slave_id_out_of_range_is_refused()
{
using namespace FroniusRules;
QCOMPARE(decideMeterConnection(248, InverterSlaveId), MeterConnectionInvalidSlaveId);
QCOMPARE(decideMeterConnection(-1, InverterSlaveId), MeterConnectionInvalidSlaveId);
// La borne haute elle-meme reste valide.
QCOMPARE(decideMeterConnection(MaximumSlaveId, InverterSlaveId), MeterConnectionCreate);
}
void SunSpecFroniusTest::test_F3_meter_slave_id_equal_to_the_inverter_is_refused()
{
using namespace FroniusRules;
// Une seconde socket sur l'unit ID deja servi par l'onduleur redecouvrirait
// exactement les memes modeles et dupliquerait chaque thing enfant. Le cas
// n'est pas theorique : la decouverte ecrit dans le parametre slaveId du
// thing l'unit ID qu'on lui a demande de scanner.
QCOMPARE(decideMeterConnection(1, 1), MeterConnectionCollidesWithInverter);
QCOMPARE(decideMeterConnection(200, 200), MeterConnectionCollidesWithInverter);
}
void SunSpecFroniusTest::test_F3_meter_slave_id_creates_the_connection()
{
using namespace FroniusRules;
QCOMPARE(decideMeterConnection(DefaultMeterSlaveId, InverterSlaveId), MeterConnectionCreate);
QCOMPARE(DefaultMeterSlaveId, 200);
// Compteurs secondaires.
QCOMPARE(decideMeterConnection(201, InverterSlaveId), MeterConnectionCreate);
QCOMPARE(decideMeterConnection(202, InverterSlaveId), MeterConnectionCreate);
}
/* AC-7 demande meterSlaveId en "advanced". Cette propriete n'existe pas sur les
* paramTypes de nymea 1.15.2 : ParamType::typeProperties() ne la connait pas, et
* le verificateur de metadonnee rejette tout champ inconnu. Une metadonnee
* rejetee, ce n'est pas un parametre mal affiche, c'est un plugin qui ne se
* charge plus du tout.
*
* Ce test n'entérine pas l'ecart : il empeche qu'on le "corrige" sans mesurer.
* Voir sunspec/DIVERGENCES.md. */
void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced()
{
const QJsonObject connection = thingClassByName("froniusConnection");
const QJsonObject meterSlaveId = paramByName(connection, "meterSlaveId");
QVERIFY(!meterSlaveId.isEmpty());
QVERIFY2(!meterSlaveId.contains("advanced"),
"\"advanced\" n'est pas une propriete de paramType connue de nymea 1.15.2 : "
"l'ajouter rend la metadonnee du plugin invalide et le plugin ne se charge plus. "
"Voir sunspec/DIVERGENCES.md.");
}
// AC-16 - signe du compteur reseau
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_16_fronius_meter_keeps_the_device_sign()
{
// Le Smart Meter Fronius emet deja dans la convention nymea : negatif =
// injection. La negation generique doit donc etre annulee pour lui.
QCOMPARE(FroniusRules::meterPowerSign(true), 1.0);
}
void SunSpecFroniusTest::test_AC_16_generic_meter_keeps_the_historical_negation()
{
// Antidote de non regression : le chemin generique et solarEdge ne bougent
// pas. Ce correctif est cible Fronius, et rien d'autre.
QCOMPARE(FroniusRules::meterPowerSign(false), -1.0);
}
void SunSpecFroniusTest::test_AC_16_field_case_export_is_reported_negative()
{
// Cas terrain du 02/08/2026, Primo GEN24 3.0 monophase (modele 101) avec un
// Smart Meter TS 65A-3 (modele 203). L'onduleur n'injecte que sur la phase
// A. Le plugin rapportait phase A = +1003 W (soutirage) et les phases B et
// C negatives, soit une injection sur deux phases sans aucune source : le
// signe etait inverse. L'arbitre voyait "Surplus insuffisant (-842 W)" et
// laissait chauffe-eau et PAC eteints alors que le site exportait.
const double sign = FroniusRules::meterPowerSign(true);
const double wattsTotal = -708.0; // registre brut du compteur
const double wattsPhaseA = -1003.1;
const double wattsPhaseB = 32.8;
const double wattsPhaseC = 261.6;
// Export vu comme export, et non plus comme soutirage.
QVERIFY(sign * wattsTotal < 0.0);
// La phase portant l'onduleur exporte ; les deux autres soutirent.
QVERIFY(sign * wattsPhaseA < 0.0);
QVERIFY(sign * wattsPhaseB > 0.0);
QVERIFY(sign * wattsPhaseC > 0.0);
}
// AC-17 - presence physique de la batterie
// ---------------------------------------------------------------------------
void SunSpecFroniusTest::test_AC_17_no_battery_means_no_storage_child()
{
// Cas fantome : le GEN24 hybride publie le modele 124 sans batterie cablee.
// Constate sur le materiel des beaux-parents, ou l'enfant batterie etait
// cree et restait bloque sur batteryCritical.
QVERIFY(!FroniusRules::hasBattery(0.0, 0.0));
}
void SunSpecFroniusTest::test_AC_17_an_installed_battery_is_still_detected()
{
// Antidote symetrique : le correctif ne doit pas faire disparaitre une
// vraie batterie. Une batterie installee porte une tension au repos.
QVERIFY(FroniusRules::hasBattery(230.4, 0.0)); // tension seule
QVERIFY(FroniusRules::hasBattery(0.0, 12.5)); // capacite dispo seule
QVERIFY(FroniusRules::hasBattery(230.4, 12.5)); // les deux
}
void SunSpecFroniusTest::test_AC_17_presence_does_not_depend_on_state_of_charge()
{
// Le SoC est volontairement hors du discriminateur : 0 % ne se distingue
// pas d'une vraie batterie vide. Une batterie a plat mais cablee garde sa
// tension, donc reste detectee.
QVERIFY(FroniusRules::hasBattery(230.4, 0.0));
}
void SunSpecFroniusTest::test_F3_generic_vendors_have_no_meter_slave_id()
{
// Le multi unit ID est une particularite Fronius. Les vendors generique et
// solarEdge gardent exactement une connexion par thing.
QVERIFY(paramByName(thingClassByName("sunspecConnection"), "meterSlaveId").isEmpty());
QVERIFY(paramByName(thingClassByName("solarEdgeConnection"), "meterSlaveId").isEmpty());
}
QTEST_MAIN(SunSpecFroniusTest)
#include "tst_sunspec.moc"