diff --git a/specs/fronius-sunspec.md b/specs/fronius-sunspec.md index 584ecad..6517faa 100644 --- a/specs/fronius-sunspec.md +++ b/specs/fronius-sunspec.md @@ -364,7 +364,19 @@ l'aiguillage du plugin, 10 sur le multi unit ID. Une quatrième suite, `tests/integration`, monte le plugin face à un vrai appareil. Elle est construite mais **jamais lancée par `make check`** : elle -exige `SUNSPEC_TEST_HOST`. **Elle n'a pas encore été exécutée sur matériel.** +exige `SUNSPEC_TEST_HOST`. + +**Exécutée le 2026-08-02 contre le simulateur `froniusgen24-sunspec` du banc : +8 tests, 8 verts.** Unit 1 = modèles 1/113/120-123/160, unit 200 = 1/213, sans +batterie. Les deux corrections qu'aucune suite hors ligne ne pouvait couvrir y +sont vérifiées, et chacune a été confirmée capable d'échouer en réintroduisant +la forme pré-F3 : le balayage des orphelins non corrigé supprime **les deux** +enfants en deux cycles de découverte, et la propagation de `connected` non +scindée fait passer le parent déconnecté sur la seule chute du compteur. + +Reste non exécuté sur **matériel réel** : le GEN24 client est une installation +distante, écartée pour ce lot (une découverte ratée y demanderait une coupure +AC+DC sur site). Build : `qmake6 NYMEA_SUNSPEC_PATH= sunspec/tests/tests.pro && make check`. Sans `NYMEA_SUNSPEC_PATH`, `sunspec.pri` résout `libnymea-sunspec` par diff --git a/sunspec/tests/integration/tst_sunspecdevice.cpp b/sunspec/tests/integration/tst_sunspecdevice.cpp index 4566b91..87cf26a 100644 --- a/sunspec/tests/integration/tst_sunspecdevice.cpp +++ b/sunspec/tests/integration/tst_sunspecdevice.cpp @@ -241,6 +241,8 @@ private slots: void test_F3_meter_connects_only_after_the_primary_discovery(); void test_F3_inverter_and_meter_children_share_one_parent(); void test_F3_no_child_disappears_across_repeated_discoveries(); + void test_F3_meter_slave_id_zero_opens_no_second_connection(); + void test_F3_losing_the_meter_leaves_the_inverter_alone(); private: QTemporaryDir m_pluginSearchDir; @@ -255,15 +257,47 @@ private: TestableSunSpecPlugin *m_plugin = nullptr; bool m_configured = false; + + /* Ces deux temoins sont ecrits par des lambdas connectees dans + * initTestCase() et relues par un test ulterieur : ce sont donc des membres, + * jamais des locales capturees par reference. Une locale de initTestCase() + * capturee par reference serait relachee au retour de la fonction, et les + * lambdas ecriraient ensuite dans une case de pile reutilisee par les frames + * suivantes - corruption silencieuse du test lui-meme. */ + bool m_primaryDiscovered = false; /* Vrai des que la connexion compteur signale connected, pour verifier que * cela n'arrive jamais avant la fin de la decouverte primaire. */ bool m_meterConnectedBeforePrimaryDiscovery = false; bool skipUnlessConfigured(); QList childrenOfKind(const ThingClassId &thingClassId) const; + void storeConnection(const ThingId &thingId, const QString &name, + const PluginId &pluginId, int meterSlaveId); + SunSpecModel *modelOn(SunSpecConnection *connection, quint16 modelId) const; }; static const ThingId DeviceThingId = ThingId("5d3f8a26-41bc-4e79-a05f-9b7c2e6d1483"); +static const ThingId DeviceNoMeterThingId = ThingId("6a4e1b07-52d8-4c93-8e7b-1f39c04a5d62"); + +void SunSpecDeviceTest::storeConnection(const ThingId &thingId, const QString &name, + const PluginId &pluginId, int meterSlaveId) +{ + NymeaSettings settings(NymeaSettings::SettingsRoleThings); + settings.beginGroup("ThingConfig"); + settings.beginGroup(thingId.toString()); + settings.setValue("thingName", name); + settings.setValue("pluginid", pluginId.toString()); + settings.setValue("thingClassId", froniusConnectionThingClassId.toString()); + settings.beginGroup("Params"); + settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), m_address.toString()); + settings.setValue(froniusConnectionThingPortParamTypeId.toString(), m_port); + settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), m_slaveId); + settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId); + settings.endGroup(); + settings.endGroup(); + settings.endGroup(); + settings.sync(); +} bool SunSpecDeviceTest::skipUnlessConfigured() { @@ -322,21 +356,11 @@ void SunSpecDeviceTest::initTestCase() const PluginMetadata metadata(document.object()); QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide"); - NymeaSettings settings(NymeaSettings::SettingsRoleThings); - settings.beginGroup("ThingConfig"); - settings.beginGroup(DeviceThingId.toString()); - settings.setValue("thingName", "Fronius"); - settings.setValue("pluginid", metadata.pluginId().toString()); - settings.setValue("thingClassId", froniusConnectionThingClassId.toString()); - settings.beginGroup("Params"); - settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), m_address.toString()); - settings.setValue(froniusConnectionThingPortParamTypeId.toString(), m_port); - settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), m_slaveId); - settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), m_meterSlaveId); - settings.endGroup(); - settings.endGroup(); - settings.endGroup(); - settings.sync(); + storeConnection(DeviceThingId, "Fronius", metadata.pluginId(), m_meterSlaveId); + /* Le cas degenere documente au contrat : 0 = pas de compteur derriere cet + * onduleur. Meme appareil, meme unit ID onduleur, mais aucune seconde + * connexion ne doit etre ouverte. */ + storeConnection(DeviceNoMeterThingId, "Fronius sans compteur", metadata.pluginId(), 0); m_hardwareManager = new DeviceHardwareManager(m_address, this); m_logEngine = new NullLogEngine(this); @@ -357,13 +381,12 @@ void SunSpecDeviceTest::initTestCase() /* Arme avant toute attente, pour que l'ordre des deux evenements soit * observe et pas reconstitue apres coup. */ - bool primaryDiscovered = false; - connect(primary, &SunSpecConnection::discoveryFinished, this, [&primaryDiscovered](bool success) { + connect(primary, &SunSpecConnection::discoveryFinished, this, [this](bool success) { if (success) - primaryDiscovered = true; + m_primaryDiscovered = true; }); - connect(meter, &SunSpecConnection::connectedChanged, this, [this, &primaryDiscovered](bool connected) { - if (connected && !primaryDiscovered) + connect(meter, &SunSpecConnection::connectedChanged, this, [this](bool connected) { + if (connected && !m_primaryDiscovered) m_meterConnectedBeforePrimaryDiscovery = true; }); @@ -452,5 +475,110 @@ void SunSpecDeviceTest::test_F3_no_child_disappears_across_repeated_discoveries( QCOMPARE(m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(), childrenBefore); } +SunSpecModel *SunSpecDeviceTest::modelOn(SunSpecConnection *connection, quint16 modelId) const +{ + foreach (SunSpecModel *model, connection->models()) { + if (model->modelId() == modelId) + return model; + } + return nullptr; +} + +/* Le cas degenere : meterSlaveId a 0 veut dire "pas de compteur derriere cet + * onduleur". Meme appareil, meme unit ID onduleur, mais une seule connexion. + * + * On n'affirme rien sur les enfants de ce second thing : le premier a deja cree + * un enfant onduleur portant le numero de serie du simulateur, et la + * deduplication par numero de serie de autocreateSunSpecModelThing() est + * globale, pas par parent. C'est la connexion qui est mesuree ici. */ +void SunSpecDeviceTest::test_F3_meter_slave_id_zero_opens_no_second_connection() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + Thing *thing = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceNoMeterThingId)) != nullptr, 5000); + QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000); + + QVERIFY2(m_plugin->testPrimaryConnection(DeviceNoMeterThingId), + "la connexion onduleur n'a pas ete creee"); + QVERIFY2(!m_plugin->testMeterConnection(DeviceNoMeterThingId), + "meterSlaveId a 0 ne doit ouvrir aucune connexion compteur"); +} + +/* Le second defaut invisible hors ligne : l'etat connected du parent suit la + * connexion primaire seule. Un Smart Meter qui tombe ne doit ni marquer + * l'onduleur injoignable, ni effacer les donnees qu'il continue de livrer. + * + * Place en dernier, parce qu'il coupe une connexion pour de bon. */ +void SunSpecDeviceTest::test_F3_losing_the_meter_leaves_the_inverter_alone() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + Thing *parent = m_thingManager->findConfiguredThing(DeviceThingId); + QVERIFY(parent); + QVERIFY2(parent->stateValue("connected").toBool(), "le parent n'etait pas connecte au depart"); + + QList inverters = childrenOfKind(sunspecThreePhaseInverterThingClassId); + QList meters = childrenOfKind(sunspecThreePhaseMeterThingClassId); + QVERIFY2(!inverters.isEmpty(), "pas d'enfant onduleur a surveiller"); + QVERIFY2(!meters.isEmpty(), "pas d'enfant compteur a couper"); + Thing *inverterThing = inverters.first(); + Thing *meterThing = meters.first(); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + QVERIFY(primary && meter); + + SunSpecModel *inverterModel = modelOn(primary, 113); + SunSpecModel *meterModel = modelOn(meter, 213); + QVERIFY2(inverterModel, "modele onduleur 113 introuvable sur la connexion primaire"); + QVERIFY2(meterModel, "modele compteur 213 introuvable sur la connexion compteur"); + + /* Un tour d'interrogation explicite. Ce banc remplace le minuteur du plugin + * par un objet creux - on ne veut pas interroger un appareil en boucle - or + * c'est la mise a jour de bloc qui met un enfant a connected. Sans ce tour, + * les enfants n'auraient jamais ete connectes et la coupure ci-dessous ne + * prouverait rien. */ + QSignalSpy inverterFirst(inverterModel, &SunSpecModel::blockUpdated); + QSignalSpy meterFirst(meterModel, &SunSpecModel::blockUpdated); + inverterModel->readBlockData(); + meterModel->readBlockData(); + QVERIFY2(inverterFirst.wait(30000), "pas de donnee onduleur avant la coupure"); + if (meterFirst.isEmpty()) + QVERIFY2(meterFirst.wait(30000), "pas de donnee compteur avant la coupure"); + + QVERIFY2(inverterThing->stateValue("connected").toBool(), + "l'enfant onduleur n'etait pas connecte avant la coupure"); + QVERIFY2(meterThing->stateValue("connected").toBool(), + "l'enfant compteur n'etait pas connecte avant la coupure"); + + // Coupure franche de la seule connexion compteur. + meter->disconnectDevice(); + QTRY_VERIFY_WITH_TIMEOUT(!meter->connected(), 15000); + + // Le parent reste connecte : c'est la primaire qui fait foi, jamais un ET logique. + QVERIFY2(parent->stateValue("connected").toBool(), + "le parent est passe deconnecte alors que seule la connexion compteur est tombee"); + QVERIFY(primary->connected()); + + // L'enfant onduleur n'est pas affecte... + QVERIFY2(inverterThing->stateValue("connected").toBool(), + "l'enfant onduleur a ete marque deconnecte par la chute du compteur"); + QVERIFY2(inverterThing->stateValue("currentPower").isValid(), + "les donnees onduleur ont ete effacees"); + + // ...et ses donnees continuent d'arriver. + QSignalSpy inverterUpdated(inverterModel, &SunSpecModel::blockUpdated); + inverterModel->readBlockData(); + QVERIFY2(inverterUpdated.wait(30000), + "plus aucune donnee onduleur apres la chute du compteur"); + + // Seul l'enfant compteur est marque deconnecte. + QVERIFY2(!meterThing->stateValue("connected").toBool(), + "l'enfant compteur aurait du etre marque deconnecte"); +} + QTEST_MAIN(SunSpecDeviceTest) #include "tst_sunspecdevice.moc"