// 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__. * * 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 #include #include #include #include #include #include #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 &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 toRegisters() const { QVector r(26, 0); r[0] = 124; // ID r[1] = 24; // L r[2] = wChaMax; r[3] = static_cast(wChaGra); r[4] = static_cast(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(outWRte); r[13] = static_cast(inWRte); r[14] = winTms; r[15] = rvrtTms; r[16] = rmpTms; r[17] = chaGriSet; r[18] = static_cast(wChaMaxSf); r[19] = static_cast(wChaDisChaGraSf); r[20] = static_cast(vaChaMaxSf); r[21] = static_cast(minRsvPctSf); r[22] = static_cast(chaStateSf); r[23] = static_cast(storAvalSf); r[24] = static_cast(inBatVSf); r[25] = static_cast(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(¬DischargingModel).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("mode"); QTest::addColumn("chargeLimit"); QTest::addColumn("dischargeLimit"); QTest::addColumn("chaGriSet"); // -1 = registre non ecrit QTest::addColumn("storCtlMod"); QTest::addColumn("inWRte"); // NaN = registre non ecrit QTest::addColumn("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(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 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 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 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 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 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 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 charging = {{"StCha 3", 2000.0}, {"StDisCha 4", 0.0}}; const QList 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 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"