// 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 . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /* Suite qui instancie reellement le plugin. * * La suite de logique pure (../logic) ne peut voir que ce que le plugin * *decide* : elle appelle des fonctions libres et lit le JSON. Elle ne peut pas * voir l'aiguillage, c'est a dire le fait qu'une thingClass soit reconnue par * setupThing(), postSetupThing() et thingRemoved(). Une thingClass oubliee dans * l'un de ces trois aiguillages tombe dans un Q_ASSERT_X final : en debug le * processus meurt, en release l'assert est compile hors du binaire et * setupThing() rend la main sans jamais appeler info->finish(). L'appairage * reste alors bloque indefiniment, sans le moindre message. * * C'est exactement ce qui est arrive a froniusConnection, et aucun test ne * pouvait le voir. * * Le plugin est monte derriere le vrai ThingManager de libnymea-core - c'est * lui qui construit les Thing et les ThingSetupInfo, et lui qui appelle * postSetupThing() - et derriere un HardwareManager double. Aucun acces reseau * n'a lieu : le moniteur double n'est jamais joignable, donc le plugin ne tente * jamais de connexion Modbus. */ #include #include #include #include #include #include #include #include #include #include #include "integrationpluginsunspec.h" /* -------------------------------------------------------------------------- * Doublures du materiel * * Le moniteur ne connait aucune adresse et n'est jamais joignable : c'est l'etat * dans lequel se trouve une connexion dont l'appareil n'a pas encore ete vu sur * le reseau. Le plugin doit y repondre, pas s'y bloquer. * -------------------------------------------------------------------------- */ class FakeNetworkDeviceMonitor : public NetworkDeviceMonitor { public: explicit FakeNetworkDeviceMonitor(QObject *parent = nullptr) : NetworkDeviceMonitor(parent) {} MacAddress macAddress() const override { return MacAddress(); } QString hostName() const override { return QString(); } QHostAddress address() const override { return QHostAddress(); } NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; } NetworkDeviceInfo networkDeviceInfo() const override { return NetworkDeviceInfo(); } bool reachable() const override { return false; } QDateTime lastSeen() const override { return QDateTime(); } uint pingRetries() const override { return 0; } void setPingRetries(uint) override {} }; class FakeNetworkDeviceDiscovery : public NetworkDeviceDiscovery { public: explicit FakeNetworkDeviceDiscovery(QObject *parent = nullptr) : NetworkDeviceDiscovery(parent) {} /* registerMonitor() n'est appele que par la branche "connexion" de * setupThing(). Compter les appels dit donc si le plugin a bien pris cette * branche, et pas seulement s'il a fini par repondre quelque chose. */ int registerMonitorCount = 0; int unregisterMonitorCount = 0; NetworkDeviceDiscoveryReply *discover() override { return nullptr; } bool running() const override { return false; } NetworkDeviceMonitor *registerMonitor(Thing *) override { registerMonitorCount++; return new FakeNetworkDeviceMonitor(this); } void unregisterMonitor(NetworkDeviceMonitor *monitor) override { unregisterMonitorCount++; 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 {} }; class FakePluginTimer : public PluginTimer { public: explicit FakePluginTimer(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; }; class FakePluginTimerManager : public PluginTimerManager { public: explicit FakePluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {} /* Le minuteur d'interrogation est arme par postSetupThing(). Le compteur dit * donc si postSetupThing() a bien traite la connexion. */ int registerTimerCount = 0; PluginTimer *registerTimer(int seconds) override { registerTimerCount++; return new FakePluginTimer(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 {} }; class FakeHardwareManager : public HardwareManager { public: explicit FakeHardwareManager(QObject *parent = nullptr) : HardwareManager(parent) , m_pluginTimerManager(new FakePluginTimerManager(this)) , m_networkDeviceDiscovery(new FakeNetworkDeviceDiscovery(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; } FakePluginTimerManager *timers() const { return m_pluginTimerManager; } FakeNetworkDeviceDiscovery *monitors() const { return m_networkDeviceDiscovery; } private: FakePluginTimerManager *m_pluginTimerManager = nullptr; FakeNetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr; }; /* setMetaData() est protege : le ThingManager le renseigne normalement depuis le * QPluginLoader. Ici le plugin est instancie en dur, donc le test lui passe la * meme metadonnee, lue dans le meme JSON. */ class TestableSunSpecPlugin : public IntegrationPluginSunSpec { public: using IntegrationPlugin::setMetaData; }; /* -------------------------------------------------------------------------- */ class SunSpecPluginTest : public QObject { Q_OBJECT private slots: void initTestCase(); void cleanupTestCase(); void test_F1_stored_fronius_connection_completes_its_setup(); void test_F1_stored_fronius_connection_is_removed_cleanly(); void test_F1_added_fronius_connection_is_answered(); void test_F1_added_fronius_connection_answers_like_the_generic_vendor(); private: QTemporaryDir m_pluginSearchDir; FakeHardwareManager *m_hardwareManager = nullptr; ThingManagerImplementation *m_thingManager = nullptr; ThingSetupInfo *addConnection(const ThingClassId &thingClassId, const QString &name); }; /* Thing deja configure, ecrit dans les reglages avant le demarrage du * ThingManager. C'est le chemin emprunte a chaque demarrage de nymead pour une * connexion deja appairee, et le seul qui aille jusqu'au bout du setup sans * appareil joignable. */ static const ThingId StoredFroniusThingId = ThingId("4e9b1c2d-6f31-4a58-91a7-2b8c0d5e7f14"); void SunSpecPluginTest::initTestCase() { /* "nymea-test" deporte les reglages dans /tmp, hors de la configuration * reelle de l'utilisateur. */ QCoreApplication::setOrganizationName("nymea-test"); NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings); thingSettings.clear(); NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins); pluginSettings.clear(); /* Repertoire de recherche vide : le seul plugin en jeu est celui que le test * enregistre lui-meme, pas ceux installes sur la machine. */ 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(StoredFroniusThingId.toString()); settings.setValue("thingName", "Fronius"); settings.setValue("pluginid", metadata.pluginId().toString()); settings.setValue("thingClassId", froniusConnectionThingClassId.toString()); /* Le ThingManager est monte sans LogEngine - son en-tete ne fait pas partie * de l'API publique de nymea-core - donc le thing ne doit journaliser aucun * state, sans quoi le ThingManager enregistrerait un logger sur un moteur * absent. Ces trois cles ecrasent les valeurs suggerees par la thingClass. */ settings.setValue("loggedStateTypeIds", QStringList()); settings.setValue("loggedEventTypeIds", QStringList()); settings.setValue("loggedActionTypeIds", QStringList()); settings.beginGroup("Params"); settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); settings.setValue(froniusConnectionThingPortParamTypeId.toString(), 502); settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), 1); settings.endGroup(); settings.endGroup(); settings.endGroup(); settings.sync(); m_hardwareManager = new FakeHardwareManager(this); m_thingManager = new ThingManagerImplementation(m_hardwareManager, nullptr, QLocale(), this); TestableSunSpecPlugin *plugin = new TestableSunSpecPlugin(); plugin->setMetaData(metadata); m_thingManager->registerStaticPlugin(plugin); QVERIFY2(!m_thingManager->findThingClass(froniusConnectionThingClassId).id().isNull(), "la thingClass froniusConnection n'est pas connue du ThingManager"); } void SunSpecPluginTest::cleanupTestCase() { delete m_thingManager; m_thingManager = nullptr; } ThingSetupInfo *SunSpecPluginTest::addConnection(const ThingClassId &thingClassId, const QString &name) { const ThingClass thingClass = m_thingManager->findThingClass(thingClassId); ParamList params; params.append(Param(thingClass.paramTypes().findByName("address").id(), QString("192.0.2.1"))); return m_thingManager->addConfiguredThing(thingClassId, params, name); } /* Le chemin du demarrage de nymead : la connexion est deja dans les reglages, * le setup n'est pas un setup initial et doit aboutir sans appareil joignable. * C'est le seul cas ou l'on peut verifier que le succes est bien annonce. * * Avant correction, setupThing() ne reconnait pas froniusConnection, ne finit * jamais, et le thing reste en ThingSetupStatusInProgress pour toujours. */ void SunSpecPluginTest::test_F1_stored_fronius_connection_completes_its_setup() { Thing *thing = nullptr; QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(StoredFroniusThingId)) != nullptr, 2000); QCOMPARE(thing->thingClassId(), froniusConnectionThingClassId); QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 2000); QCOMPARE(thing->setupError(), Thing::ThingErrorNoError); /* La branche connexion de setupThing() a bien ete prise... */ QCOMPARE(m_hardwareManager->monitors()->registerMonitorCount, 1); /* ...et postSetupThing() a arme le minuteur d'interrogation. */ QCOMPARE(m_hardwareManager->timers()->registerTimerCount, 1); } /* thingRemoved() est indexe sur m_sunSpecConnections, pas sur la thingClass : il * n'a donc rien de specifique a faire pour Fronius. Verifie plutot que suppose. */ void SunSpecPluginTest::test_F1_stored_fronius_connection_is_removed_cleanly() { QVERIFY(m_thingManager->findConfiguredThing(StoredFroniusThingId)); const int unregisteredBefore = m_hardwareManager->monitors()->unregisterMonitorCount; QCOMPARE(m_thingManager->removeConfiguredThing(StoredFroniusThingId), Thing::ThingErrorNoError); QVERIFY(!m_thingManager->findConfiguredThing(StoredFroniusThingId)); QCOMPARE(m_hardwareManager->monitors()->unregisterMonitorCount, unregisteredBefore + 1); } /* Le chemin de l'appairage. Sans adresse connue, un setup initial se termine en * ThingErrorHardwareFailure : c'est un refus explicite, pas un blocage. Avant * correction, aucune reponse n'arrive jamais. */ void SunSpecPluginTest::test_F1_added_fronius_connection_is_answered() { const int monitorsBefore = m_hardwareManager->monitors()->registerMonitorCount; ThingSetupInfo *info = addConnection(froniusConnectionThingClassId, "Fronius appaire"); QVERIFY(info); if (!info->isFinished()) { QSignalSpy finished(info, &ThingSetupInfo::finished); QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish()"); } QCOMPARE(m_hardwareManager->monitors()->registerMonitorCount, monitorsBefore + 1); QCOMPARE(info->status(), Thing::ThingErrorHardwareFailure); QVERIFY(!info->displayMessage().isEmpty()); } /* Le vendor generique passe par exactement le meme chemin. Si les deux reponses * divergent, c'est que froniusConnection a ete branche autrement, pas comme les * vendors deja fonctionnels. */ void SunSpecPluginTest::test_F1_added_fronius_connection_answers_like_the_generic_vendor() { ThingSetupInfo *generic = addConnection(sunspecConnectionThingClassId, "SunSpec appaire"); QVERIFY(generic); if (!generic->isFinished()) { QSignalSpy finished(generic, &ThingSetupInfo::finished); QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish() pour sunspecConnection"); } ThingSetupInfo *fronius = addConnection(froniusConnectionThingClassId, "Fronius appaire 2"); QVERIFY(fronius); if (!fronius->isFinished()) { QSignalSpy finished(fronius, &ThingSetupInfo::finished); QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish() pour froniusConnection"); } QCOMPARE(fronius->status(), generic->status()); QCOMPARE(fronius->displayMessage(), generic->displayMessage()); } QTEST_MAIN(SunSpecPluginTest) #include "tst_sunspecplugin.moc"