// 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(); 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; 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; /* 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() { 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"); 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); 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. */ connect(primary, &SunSpecConnection::discoveryFinished, this, [this](bool success) { if (success) m_primaryDiscovered = true; }); connect(meter, &SunSpecConnection::connectedChanged, this, [this](bool connected) { if (connected && !m_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); } 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"