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

1171 lines
46 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();
// D-9 - interface energystorage du sunspecStorage
void test_D9_storage_declares_energystorage_without_losing_battery();
void test_D9_storage_exposes_current_power_in_watt();
void test_D9_storage_exposes_capacity_in_kilowatthour();
void test_D9_current_power_is_positive_when_charging();
void test_D9_current_power_is_negative_when_discharging();
void test_D9_current_power_is_the_opposite_of_battery_power();
void test_D9_current_power_ignores_the_pv_strings();
void test_D9_battery_blocks_are_detected_by_id_string();
void test_D9_an_inverter_without_battery_has_no_battery_block();
void test_D9_capacity_converts_watthours_to_kilowatthours();
void test_D9_a_nameplate_without_capacity_is_refused();
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_16_three_phase_meter_export_on_every_phase();
void test_AC_18_swapped_words_yield_denormals();
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()
{
// MESURE le 2026-08-07 sur un Symo reel, en lecture seule : l'appareil emet
// en big endian, mot de poids fort d'abord, conformement a la specification
// SunSpec.
//
// compteur 213 unite 200 : Hz = 4248 0000 -> 50.0000
// onduleur 113 unite 1 : Hz = 4247 fc48 -> 49.9964
//
// Ce test affirmait l'inverse, avec pour justification « on suppose le
// defaut SunSpec ». La supposition confondait le defaut de la SPEC avec
// celui de libnymea-sunspec, qui est son contraire. Le nom du test est
// conserve pour ne pas casser les references, mais « sunspec_default »
// designe desormais le defaut de la specification, pas celui de la lib.
QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderBigEndian);
}
void SunSpecFroniusTest::test_AC_18_swapped_words_yield_denormals()
{
// Antidote : verrouille le RAISON du choix, pas seulement le choix.
// Les registres ci-dessous sont ceux releves sur le Symo. Lus dans le bon
// ordre ils donnent une frequence reseau ; lus mots inverses, un denormal.
// Si quelqu'un rebascule byteOrder() en little endian, c'est cette lecture
// absurde que le plugin produira sur du materiel reel.
const quint16 hi = 0x4248, lo = 0x0000; // Hz du compteur 213
auto asFloat = [](quint16 first, quint16 second) {
const quint32 raw = (quint32(first) << 16) | second;
float value;
memcpy(&value, &raw, sizeof(raw));
return value;
};
// Big endian : le registre de poids fort d'abord.
QVERIFY(qAbs(asFloat(hi, lo) - 50.0f) < 0.01f);
// Mots inverses : un denormal, jamais une mesure plausible.
const float swapped = asFloat(lo, hi);
QVERIFY(swapped > 0.0f);
QVERIFY(swapped < 1.0e-40f);
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
/* D-9 - Le Thing declare `battery` mais pas `energystorage`, donc les trois
* consommateurs qui filtrent par `energystorage` ne le trouvent pas : le bilan
* de puissance (currentPowerStorage, celui du tableau de bord client), la
* reserve de recharge VE, et la reserve du waterfall. Voir DIVERGENCES.md D-9. */
void SunSpecFroniusTest::test_D9_storage_declares_energystorage_without_losing_battery()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
QStringList interfaces;
foreach (const QJsonValue &value, storage.value("interfaces").toArray())
interfaces.append(value.toString());
QVERIFY2(interfaces.contains("energystorage"),
"sans energystorage, filterByInterface ne trouve pas la batterie");
// `battery` est conservee : energystorage l'etend, et d'autres choses
// peuvent en dependre.
QVERIFY(interfaces.contains("battery"));
QVERIFY(interfaces.contains("connectable"));
}
void SunSpecFroniusTest::test_D9_storage_exposes_current_power_in_watt()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
const QJsonObject state = stateByName(storage, "currentPower");
QVERIFY2(!state.isEmpty(), "energystorage exige currentPower");
// Type et unite sont verifies par PluginMetadata contre l'interface : s'ils
// divergent, le plugin ne se charge plus du tout.
QCOMPARE(state.value("type").toString(), QString("double"));
QCOMPARE(state.value("unit").toString(), QString("Watt"));
}
void SunSpecFroniusTest::test_D9_storage_exposes_capacity_in_kilowatthour()
{
const QJsonObject storage = thingClassByName("sunspecStorage");
QVERIFY(!storage.isEmpty());
const QJsonObject state = stateByName(storage, "capacity");
QVERIFY2(!state.isEmpty(), "energystorage exige capacity");
QCOMPARE(state.value("type").toString(), QString("double"));
QCOMPARE(state.value("unit").toString(), QString("KiloWattHour"));
}
/* Convention nymea, relevee dans libnymea/interfaces/energystorage.json :
* positif = consomme. Une batterie qui charge consomme.
*
* Mesure du 2026-08-26 sur le banc (.211 unit 1, modele 160 @40265), consigne
* batterie imposee par MQTT et registres relus en lecture seule :
* consigne +2000 W -> StCha 2000,0 W StDisCha 0,0 W ChaSt Charging
* consigne -1500 W -> StCha 0,0 W StDisCha 1500,0 W ChaSt Discharging
* Le signe n'est donc pas deduit de la documentation, il est constate. */
void SunSpecFroniusTest::test_D9_current_power_is_positive_when_charging()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 496.5},
{"MPPT 2", 496.5},
{"StCha 3", 2000.0},
{"StDisCha 4", 0.0}
};
QCOMPARE(storageCurrentPower(readings), 2000.0);
}
void SunSpecFroniusTest::test_D9_current_power_is_negative_when_discharging()
{
using namespace FroniusRules;
const QList<ModuleReading> readings = {
{"MPPT 1", 621.8},
{"MPPT 2", 621.8},
{"StCha 3", 0.0},
{"StDisCha 4", 1500.0}
};
QCOMPARE(storageCurrentPower(readings), -1500.0);
}
/* Les deux conventions coexistent dans le plugin - celle du constructeur pour
* le contrat, celle de nymea pour l'etat. Ce test les tient dos a dos : une
* seule des deux peut changer de signe sans que rien ne le dise. */
void SunSpecFroniusTest::test_D9_current_power_is_the_opposite_of_battery_power()
{
using namespace FroniusRules;
const QList<ModuleReading> charging = {{"StCha 3", 2000.0}, {"StDisCha 4", 0.0}};
const QList<ModuleReading> discharging = {{"StCha 3", 0.0}, {"StDisCha 4", 1500.0}};
QCOMPARE(storageCurrentPower(charging), -batteryPower(charging));
QCOMPARE(storageCurrentPower(discharging), -batteryPower(discharging));
}
void SunSpecFroniusTest::test_D9_current_power_ignores_the_pv_strings()
{
using namespace FroniusRules;
// Les strings PV ont ete a 746,4 W chacun pendant la phase de repos du
// releve, batterie a l'arret. Les compter donnerait 1492,8 W de charge.
const QList<ModuleReading> readings = {
{"MPPT 1", 746.4},
{"MPPT 2", 746.4},
{"StCha 3", 0.0},
{"StDisCha 4", 0.0}
};
QCOMPARE(storageCurrentPower(readings), 0.0);
}
void SunSpecFroniusTest::test_D9_battery_blocks_are_detected_by_id_string()
{
using namespace FroniusRules;
QVERIFY(hasBatteryBlocks({{"MPPT 1", 1200.0}, {"StCha 3", 0.0}}));
QVERIFY(hasBatteryBlocks({{"MPPT 1", 1200.0}, {"StDisCha 4", 0.0}}));
}
/* La distinction que ce test protege : storageCurrentPower() repond 0 aussi
* bien pour une batterie a l'arret que pour un onduleur qui n'en a pas. Sans
* hasBatteryBlocks(), le plugin publierait un 0 confiant sur une machine qui
* n'a rien a mesurer - exactement le zero qui est pire que l'absence. */
void SunSpecFroniusTest::test_D9_an_inverter_without_battery_has_no_battery_block()
{
using namespace FroniusRules;
// Un GEN24 sans batterie publie N=2 : les deux strings PV, rien d'autre.
QVERIFY(!hasBatteryBlocks({{"MPPT 1", 1200.0}, {"MPPT 2", 800.0}}));
QVERIFY(!hasBatteryBlocks({}));
}
void SunSpecFroniusTest::test_D9_capacity_converts_watthours_to_kilowatthours()
{
QCOMPARE(SunSpecStorageLogic::nameplateCapacityKwh(10000.0), 10.0);
QCOMPARE(SunSpecStorageLogic::nameplateCapacityKwh(7500.0), 7.5);
}
/* WHRtg non implemente vaut 0xFFFF, que libnymea-sunspec rend comme un point de
* donnee invalide laisse a 0 - indistinguable d'un nameplate qui annonce
* vraiment zero. Les deux veulent dire "inconnu", et aucun des deux ne doit
* etre publie comme une mesure. */
void SunSpecFroniusTest::test_D9_a_nameplate_without_capacity_is_refused()
{
QVERIFY(SunSpecStorageLogic::nameplateCapacityKwh(0.0) < 0);
QVERIFY(SunSpecStorageLogic::nameplateCapacityKwh(-1.0) < 0);
}
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);
}
void SunSpecFroniusTest::test_AC_16_three_phase_meter_export_on_every_phase()
{
// Reproduit sur le simulateur, profil Symo triphase (onduleur modele 103,
// compteur modele 203). Le compteur emet -500 / -100 / -100 W, soit un
// export sur les trois phases. Le plugin etm6 rapportait +500 / +100 /
// +100, c'est a dire du soutirage sur les trois. Le chemin triphase est
// celui du materiel terrain : il merite son propre verrou.
const double sign = FroniusRules::meterPowerSign(true);
QCOMPARE(sign * -500.0, -500.0);
QCOMPARE(sign * -100.0, -100.0);
// Et le total suit la meme regle, quel que soit son signe.
QCOMPARE(sign * -700.0, -700.0);
QCOMPARE(sign * 3262.0, 3262.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"