diff --git a/sunspec/tests/integration/integration.pro b/sunspec/tests/integration/integration.pro new file mode 100644 index 0000000..e33bbfa --- /dev/null +++ b/sunspec/tests/integration/integration.pro @@ -0,0 +1,75 @@ +include(../../../sunspec.pri) + +QT += testlib network serialbus +QT -= gui +TEMPLATE = app +TARGET = tst_sunspecdevice + +# Suite d'integration F3 : le plugin, monte derriere un vrai ThingManager, face +# a un vrai appareil. +# +# VOLONTAIREMENT PAS "testcase" : "make check" ne la lance jamais. Elle est +# quand meme construite, pour qu'elle ne pourrisse pas en silence. +# +# Elle exige un appareil joignable, passe par variable d'environnement : +# +# SUNSPEC_TEST_HOST= [SUNSPEC_TEST_PORT=502] +# [SUNSPEC_TEST_METER_SLAVE_ID=200] [SUNSPEC_TEST_SLAVE_ID=1] +# ./tst_sunspecdevice +# +# Sans SUNSPEC_TEST_HOST, chaque test se met en QSKIP. Aucune adresse par +# defaut n'est ecrite ici : l'adresse d'une installation reelle est une donnee +# client et n'a rien a faire dans un depot a miroir public (voir AGENTS.md). +# +# ATTENTION avant de la lancer sur un onduleur client : le constructeur +# recommande de rester au-dessus de 7 s d'intervalle d'interrogation, faute de +# quoi l'onduleur peut passer hors ligne vis-a-vis de son portail, avec +# retablissement par coupure AC+DC sur site. Cette suite ne fait qu'un cycle de +# decouverte et n'interroge pas en boucle, mais elle ouvre bien deux connexions. + +CONFIG += console c++17 +CONFIG -= app_bundle + +CONFIG += link_pkgconfig +PKGCONFIG += nymea nymea-core + +PLUGIN_DIR = $$PWD/../.. +INCLUDEPATH += $$PLUGIN_DIR + +DEFINES += SUNSPEC_PLUGIN_TEST_ACCESS + +JSONFILE = $$PLUGIN_DIR/integrationpluginsunspec.json +OTHER_FILES += $$JSONFILE + +plugininfo.input = JSONFILE +plugininfo.output = plugininfo.h +plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --output ${QMAKE_FILE_OUT} +plugininfo.dependency_type = TYPE_C +plugininfo.CONFIG += no_link combine +QMAKE_EXTRA_COMPILERS += plugininfo + +extern_plugininfo.input = JSONFILE +extern_plugininfo.output = extern-plugininfo.h +extern_plugininfo.commands = nymea-plugininfocompiler ${QMAKE_FILE_NAME} --extern ${QMAKE_FILE_OUT} +extern_plugininfo.dependency_type = TYPE_C +extern_plugininfo.CONFIG += no_link combine +QMAKE_EXTRA_COMPILERS += extern_plugininfo + +DEFINES += PLUGIN_JSON_PATH=\\\"$$JSONFILE\\\" + +SOURCES += \ + tst_sunspecdevice.cpp \ + $$PLUGIN_DIR/integrationpluginsunspec.cpp \ + $$PLUGIN_DIR/froniusrules.cpp \ + $$PLUGIN_DIR/solaredgebattery.cpp \ + $$PLUGIN_DIR/sunspecdiscovery.cpp \ + $$PLUGIN_DIR/sunspecstoragelogic.cpp \ + $$PLUGIN_DIR/sunspecthing.cpp + +HEADERS += \ + $$PLUGIN_DIR/integrationpluginsunspec.h \ + $$PLUGIN_DIR/froniusrules.h \ + $$PLUGIN_DIR/solaredgebattery.h \ + $$PLUGIN_DIR/sunspecdiscovery.h \ + $$PLUGIN_DIR/sunspecstoragelogic.h \ + $$PLUGIN_DIR/sunspecthing.h diff --git a/sunspec/tests/integration/tst_sunspecdevice.cpp b/sunspec/tests/integration/tst_sunspecdevice.cpp new file mode 100644 index 0000000..4566b91 --- /dev/null +++ b/sunspec/tests/integration/tst_sunspecdevice.cpp @@ -0,0 +1,456 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2026, ETM-PowerSync +* +* This file is part of nymea. +* +* nymea is free software: you can redistribute it and/or modify it under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, either version 3 of the License, or (at your option) any later +* version. +* +* nymea is distributed in the hope that it will be useful, but WITHOUT ANY +* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +* A PARTICULAR PURPOSE. See the GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* nymea. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +/* F3 face a un vrai appareil. + * + * Les deux defauts les plus certains du multi unit ID sont invisibles pour les + * suites hors ligne, parce qu'ils n'apparaissent qu'une fois la decouverte + * reellement passee : + * + * - le balayage des orphelins, qui sur une seule des deux connexions ferait + * disparaitre les enfants de l'autre a chaque decouverte ; + * - l'agregation de l'etat connected entre les deux connexions. + * + * Cette suite n'est pas lancee par "make check" : elle exige un appareil. Voir + * integration.pro pour les variables d'environnement. Sans SUNSPEC_TEST_HOST, + * tout se met en QSKIP. + * + * Aucune adresse par defaut n'est ecrite ici. L'adresse d'une installation + * reelle est une donnee client (AGENTS.md, "Miroir public"). + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "integrationpluginsunspec.h" + +/* -------------------------------------------------------------------------- + * Doublures + * -------------------------------------------------------------------------- */ + +/* Contrairement aux suites hors ligne, le moniteur porte ici une vraie adresse + * et se declare joignable : c'est ce qui autorise le plugin a se connecter. */ +class DeviceMonitor : public NetworkDeviceMonitor +{ +public: + DeviceMonitor(const QHostAddress &address, QObject *parent = nullptr) + : NetworkDeviceMonitor(parent), m_address(address) + { + m_info = NetworkDeviceInfo(address); + m_info.setMonitorMode(NetworkDeviceInfo::MonitorModeIp); + } + + MacAddress macAddress() const override { return MacAddress(); } + QString hostName() const override { return QString(); } + QHostAddress address() const override { return m_address; } + NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; } + NetworkDeviceInfo networkDeviceInfo() const override { return m_info; } + bool reachable() const override { return true; } + QDateTime lastSeen() const override { return QDateTime::currentDateTime(); } + uint pingRetries() const override { return 0; } + void setPingRetries(uint) override {} + +private: + QHostAddress m_address; + NetworkDeviceInfo m_info; +}; + +class DeviceDiscovery : public NetworkDeviceDiscovery +{ +public: + DeviceDiscovery(const QHostAddress &address, QObject *parent = nullptr) + : NetworkDeviceDiscovery(parent), m_address(address) {} + + NetworkDeviceDiscoveryReply *discover() override { return nullptr; } + bool running() const override { return false; } + + NetworkDeviceMonitor *registerMonitor(Thing *) override { return new DeviceMonitor(m_address, this); } + void unregisterMonitor(NetworkDeviceMonitor *monitor) override { delete monitor; } + + PingReply *ping(const QHostAddress &, uint) override { return nullptr; } + PingReply *ping(const QString &, uint) override { return nullptr; } + MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; } + MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; } + bool sendArpRequest(const QHostAddress &) override { return false; } + NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); } + + bool available() const override { return true; } + bool enabled() const override { return true; } + +protected: + void setEnabled(bool) override {} + +private: + QHostAddress m_address; +}; + +class NullPluginTimer : public PluginTimer +{ +public: + explicit NullPluginTimer(int seconds, QObject *parent = nullptr) + : PluginTimer(parent), m_interval(seconds) {} + + int interval() const override { return m_interval; } + int currentTick() const override { return 0; } + bool running() const override { return m_running; } + + void reset() override {} + void start() override { m_running = true; } + void stop() override { m_running = false; } + void pause() override {} + void resume() override {} + +private: + int m_interval = 0; + bool m_running = false; +}; + +/* Le minuteur n'est jamais declenche par cette suite. Interroger en boucle un + * onduleur client est precisement ce que le plancher de cadence cherche a + * eviter ; ici on ne veut que la decouverte. */ +class NullPluginTimerManager : public PluginTimerManager +{ +public: + explicit NullPluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {} + + PluginTimer *registerTimer(int seconds) override { return new NullPluginTimer(seconds, this); } + void unregisterTimer(PluginTimer *timer) override { delete timer; } + + bool available() const override { return true; } + bool enabled() const override { return true; } + +protected: + void setEnabled(bool) override {} +}; + +/* Les things enfants sont crees par le plugin, pas ecrits d'avance dans les + * reglages : on ne peut donc pas leur retirer leurs states journalises comme le + * font les suites hors ligne. Il faut un LogEngine, meme creux, sans quoi le + * ThingManager en dereference un absent des la creation du premier enfant. */ +class NullLogEngine : public LogEngine +{ +public: + explicit NullLogEngine(QObject *parent = nullptr) : LogEngine(parent) {} + + Logger *registerLogSource(const QString &name, const QStringList &tags, + Types::LoggingType loggingType, const QString &) override + { + Logger *logger = createLogger(name, tags, loggingType); + m_loggers.insert(name, logger); + return logger; + } + + void unregisterLogSource(const QString &name) override { m_loggers.remove(name); } + + LogFetchJob *fetchLogEntries(const QStringList &, const QStringList &, const QDateTime &, + const QDateTime &, const QVariantMap &, Types::SampleRate, + Qt::SortOrder, int, int) override + { + LogFetchJob *job = new LogFetchJob(this); + finishFetchJob(job, LogEntries()); + return job; + } + + bool jobsRunning() const override { return false; } + void clear(const QString &) override {} + void enable() override {} + void disable() override {} + +private: + void logEvent(Logger *, const QStringList &, const QVariantMap &) override {} + + QHash m_loggers; +}; + +class DeviceHardwareManager : public HardwareManager +{ +public: + DeviceHardwareManager(const QHostAddress &address, QObject *parent = nullptr) + : HardwareManager(parent) + , m_pluginTimerManager(new NullPluginTimerManager(this)) + , m_networkDeviceDiscovery(new DeviceDiscovery(address, this)) + {} + + Radio433 *radio433() override { return nullptr; } + PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; } + NetworkAccessManager *networkManager() override { return nullptr; } + UpnpDiscovery *upnpDiscovery() override { return nullptr; } + PlatformZeroConfController *zeroConfController() override { return nullptr; } + BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; } + MqttProvider *mqttProvider() override { return nullptr; } + I2CManager *i2cManager() override { return nullptr; } + ZigbeeHardwareResource *zigbeeResource() override { return nullptr; } + ZWaveHardwareResource *zwaveResource() override { return nullptr; } + ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; } + NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; } + +private: + NullPluginTimerManager *m_pluginTimerManager = nullptr; + DeviceDiscovery *m_networkDeviceDiscovery = nullptr; +}; + +class TestableSunSpecPlugin : public IntegrationPluginSunSpec +{ +public: + using IntegrationPlugin::setMetaData; +}; + +/* -------------------------------------------------------------------------- */ + +class SunSpecDeviceTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void cleanupTestCase(); + + void test_F3_both_connections_reach_the_device(); + 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(); + +private: + QTemporaryDir m_pluginSearchDir; + QHostAddress m_address; + int m_port = 502; + int m_slaveId = 1; + int m_meterSlaveId = 200; + + DeviceHardwareManager *m_hardwareManager = nullptr; + ThingManagerImplementation *m_thingManager = nullptr; + NullLogEngine *m_logEngine = nullptr; + TestableSunSpecPlugin *m_plugin = nullptr; + + bool m_configured = 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; +}; + +static const ThingId DeviceThingId = ThingId("5d3f8a26-41bc-4e79-a05f-9b7c2e6d1483"); + +bool SunSpecDeviceTest::skipUnlessConfigured() +{ + if (m_configured) + return false; + + /* QSKIP ne peut pas etre appele depuis une fonction auxiliaire, donc le + * message est laisse a l'appelant. */ + return true; +} + +QList SunSpecDeviceTest::childrenOfKind(const ThingClassId &thingClassId) const +{ + QList found; + foreach (Thing *thing, m_thingManager->configuredThings()) { + if (thing->thingClassId() == thingClassId && thing->parentId() == DeviceThingId) + found.append(thing); + } + return found; +} + +void SunSpecDeviceTest::initTestCase() +{ + const QByteArray host = qgetenv("SUNSPEC_TEST_HOST"); + if (host.isEmpty()) { + qInfo() << "SUNSPEC_TEST_HOST is not set: every test will be skipped."; + return; + } + + m_address = QHostAddress(QString::fromUtf8(host)); + QVERIFY2(!m_address.isNull(), "SUNSPEC_TEST_HOST is not a valid address"); + + if (!qgetenv("SUNSPEC_TEST_PORT").isEmpty()) + m_port = qgetenv("SUNSPEC_TEST_PORT").toInt(); + if (!qgetenv("SUNSPEC_TEST_SLAVE_ID").isEmpty()) + m_slaveId = qgetenv("SUNSPEC_TEST_SLAVE_ID").toInt(); + if (!qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").isEmpty()) + m_meterSlaveId = qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").toInt(); + + QCoreApplication::setOrganizationName("nymea-test-sunspec-device"); + + NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings); + thingSettings.clear(); + NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins); + pluginSettings.clear(); + + QVERIFY(m_pluginSearchDir.isValid()); + qputenv("NYMEA_PLUGINS_PATH", m_pluginSearchDir.path().toUtf8()); + + QFile jsonFile(PLUGIN_JSON_PATH); + QVERIFY2(jsonFile.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH))); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(jsonFile.readAll(), &error); + QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString())); + + 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(); + + m_hardwareManager = new DeviceHardwareManager(m_address, this); + m_logEngine = new NullLogEngine(this); + m_thingManager = new ThingManagerImplementation(m_hardwareManager, m_logEngine, QLocale(), this); + + m_plugin = new TestableSunSpecPlugin(); + m_plugin->setMetaData(metadata); + m_thingManager->registerStaticPlugin(m_plugin); + + Thing *thing = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceThingId)) != nullptr, 5000); + QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + QVERIFY(primary); + QVERIFY2(meter, "aucune connexion compteur creee : verifier SUNSPEC_TEST_METER_SLAVE_ID"); + + /* 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) { + if (success) + primaryDiscovered = true; + }); + connect(meter, &SunSpecConnection::connectedChanged, this, [this, &primaryDiscovered](bool connected) { + if (connected && !primaryDiscovered) + m_meterConnectedBeforePrimaryDiscovery = true; + }); + + m_configured = true; +} + +void SunSpecDeviceTest::cleanupTestCase() +{ + delete m_thingManager; + m_thingManager = nullptr; +} + +void SunSpecDeviceTest::test_F3_both_connections_reach_the_device() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + QVERIFY(primary && meter); + + QTRY_VERIFY_WITH_TIMEOUT(primary->connected(), 30000); + QTRY_VERIFY_WITH_TIMEOUT(meter->connected(), 60000); + + QCOMPARE(static_cast(primary->slaveId()), m_slaveId); + QCOMPARE(static_cast(meter->slaveId()), m_meterSlaveId); + + // Les deux ont trouve un registre de base SunSpec, chacune sur son unit ID. + QTRY_VERIFY_WITH_TIMEOUT(!primary->models().isEmpty(), 60000); + QTRY_VERIFY_WITH_TIMEOUT(!meter->models().isEmpty(), 60000); +} + +/* Q4. Deux decouvertes SunSpec simultanees seraient la charge la plus lourde + * que ce plugin fasse jamais subir a un onduleur. */ +void SunSpecDeviceTest::test_F3_meter_connects_only_after_the_primary_discovery() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + QVERIFY2(!m_meterConnectedBeforePrimaryDiscovery, + "la connexion compteur est montee avant la fin de la decouverte primaire"); +} + +/* Un seul appareil a appairer, tous les enfants sous le meme parent. */ +void SunSpecDeviceTest::test_F3_inverter_and_meter_children_share_one_parent() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseInverterThingClassId).isEmpty() + || !childrenOfKind(sunspecSplitPhaseInverterThingClassId).isEmpty() + || !childrenOfKind(sunspecSinglePhaseInverterThingClassId).isEmpty(), 60000); + + QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseMeterThingClassId).isEmpty() + || !childrenOfKind(sunspecSplitPhaseMeterThingClassId).isEmpty() + || !childrenOfKind(sunspecSinglePhaseMeterThingClassId).isEmpty(), 60000); +} + +/* Le defaut le plus certain du multi unit ID : chaque connexion ne voit que la + * moitie du tableau, donc un balayage des orphelins fait sur cette moitie ferait + * disparaitre les enfants de l'autre. Une redecouverte suffit a le declencher. */ +void SunSpecDeviceTest::test_F3_no_child_disappears_across_repeated_discoveries() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + const int childrenBefore = m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(); + QVERIFY2(childrenBefore > 0, "aucun enfant a surveiller"); + + QSignalSpy disappeared(m_plugin, &IntegrationPlugin::autoThingDisappeared); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + + for (int round = 0; round < 2; round++) { + QSignalSpy primaryDone(primary, &SunSpecConnection::discoveryFinished); + primary->startDiscovery(); + QVERIFY2(primaryDone.wait(60000), "la decouverte primaire n'a pas rendu la main"); + + QSignalSpy meterDone(meter, &SunSpecConnection::discoveryFinished); + meter->startDiscovery(); + QVERIFY2(meterDone.wait(60000), "la decouverte compteur n'a pas rendu la main"); + } + + QCOMPARE(disappeared.count(), 0); + QCOMPARE(m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(), childrenBefore); +} + +QTEST_MAIN(SunSpecDeviceTest) +#include "tst_sunspecdevice.moc" diff --git a/sunspec/tests/tests.pro b/sunspec/tests/tests.pro index 7c745ad..9eadc09 100644 --- a/sunspec/tests/tests.pro +++ b/sunspec/tests/tests.pro @@ -9,7 +9,12 @@ TEMPLATE = subdirs # portant deux SunSpecConnection (F3). Binaire distinct pour ne pas # fausser les compteurs d'appels globaux verifies par plugin. +# integration : le plugin face a un vrai appareil. Construite mais JAMAIS lancee +# par "make check" : elle exige un onduleur joignable, passe par +# SUNSPEC_TEST_HOST. Voir integration/integration.pro. + SUBDIRS += \ logic \ plugin \ - multiunit + multiunit \ + integration