// 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 - un thing froniusConnection, deux unit IDs. * * Le GEN24 repond pour l'onduleur sur l'unit ID 1 et pour le Smart Meter sur * l'unit ID 200 par defaut, derriere le meme host:port. Le plugin ouvre donc * deux SunSpecConnection pour un seul thing. * * Ce que cette suite couvre : la creation, le nombre, les unit IDs, et surtout * la destruction - une connexion compteur qui survit a son thing garde une * socket ouverte et des signaux pointant vers un Thing detruit. * * Ce qu'elle ne couvre pas, faute de pair Modbus : la decouverte, la creation * des things enfants, le balayage des orphelins et l'agregation de l'etat * connected. Voir ../integration. * * Binaire distinct de ../plugin : la suite F1 y verifie des compteurs d'appels * globaux que des things configures supplementaires feraient mentir. */ #include #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 : aucune connexion Modbus n'est donc tentee. * -------------------------------------------------------------------------- */ 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) {} 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) {} PluginTimer *registerTimer(int seconds) override { 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; } FakeNetworkDeviceDiscovery *monitors() const { return m_networkDeviceDiscovery; } private: FakePluginTimerManager *m_pluginTimerManager = nullptr; FakeNetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr; }; class TestableSunSpecPlugin : public IntegrationPluginSunSpec { public: using IntegrationPlugin::setMetaData; }; /* -------------------------------------------------------------------------- */ class SunSpecMultiUnitTest : public QObject { Q_OBJECT private slots: void initTestCase(); void cleanupTestCase(); void test_F3_fronius_connection_creates_two_connections(); void test_F3_meter_connection_uses_the_meter_slave_id(); void test_F3_meter_connection_stays_idle_until_the_primary_discovered(); void test_F3_meter_slave_id_zero_creates_only_one_connection(); void test_F3_meter_slave_id_colliding_with_the_inverter_is_refused(); void test_F3_generic_connection_creates_no_meter_connection(); void test_F3_solaredge_connection_creates_no_meter_connection(); void test_F3_removal_destroys_both_connections(); private: QTemporaryDir m_pluginSearchDir; FakeHardwareManager *m_hardwareManager = nullptr; ThingManagerImplementation *m_thingManager = nullptr; TestableSunSpecPlugin *m_plugin = nullptr; void storeConnection(const ThingId &thingId, const ThingClassId &thingClassId, const QString &name, const PluginId &pluginId, int meterSlaveId); Thing *awaitThing(const ThingId &thingId); }; /* Things deja configures, ecrits 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 FroniusWithMeterId = ThingId("1f0a5c7e-3b42-4d18-9e6a-70c2d4b85f31"); static const ThingId FroniusWithoutMeterId = ThingId("2c7d9e14-8a56-4f03-b21d-63e8a95c4072"); static const ThingId FroniusCollidingId = ThingId("3b8e2f60-95c1-42a7-8d34-51fa7e60b9c8"); static const ThingId GenericConnectionId = ThingId("4a1c6d83-27e9-4b50-a6f2-8c05d31e7ba4"); static const ThingId SolarEdgeConnectionId = ThingId("5e2b7f49-6d38-41ca-b95e-0a4c8d71e263"); static const int MeterSlaveId = 200; static const int InverterSlaveId = 1; void SunSpecMultiUnitTest::storeConnection(const ThingId &thingId, const ThingClassId &thingClassId, 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", thingClassId.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. */ settings.setValue("loggedStateTypeIds", QStringList()); settings.setValue("loggedEventTypeIds", QStringList()); settings.setValue("loggedActionTypeIds", QStringList()); settings.beginGroup("Params"); if (thingClassId == froniusConnectionThingClassId) { settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); settings.setValue(froniusConnectionThingPortParamTypeId.toString(), 502); settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId); settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId); } else if (thingClassId == solarEdgeConnectionThingClassId) { settings.setValue(solarEdgeConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); settings.setValue(solarEdgeConnectionThingPortParamTypeId.toString(), 502); settings.setValue(solarEdgeConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId); } else { settings.setValue(sunspecConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); settings.setValue(sunspecConnectionThingPortParamTypeId.toString(), 502); settings.setValue(sunspecConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId); } settings.endGroup(); settings.endGroup(); settings.endGroup(); settings.sync(); } Thing *SunSpecMultiUnitTest::awaitThing(const ThingId &thingId) { Thing *thing = nullptr; [&]() { QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(thingId)) != nullptr, 2000); QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 2000); }(); return thing; } void SunSpecMultiUnitTest::initTestCase() { /* Nom d'organisation propre a ce binaire : les reglages ne croisent donc ni * ceux de l'utilisateur ni ceux de la suite ../plugin. */ QCoreApplication::setOrganizationName("nymea-test-sunspec-multiunit"); 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(FroniusWithMeterId, froniusConnectionThingClassId, "Fronius avec compteur", metadata.pluginId(), MeterSlaveId); storeConnection(FroniusWithoutMeterId, froniusConnectionThingClassId, "Fronius sans compteur", metadata.pluginId(), 0); storeConnection(FroniusCollidingId, froniusConnectionThingClassId, "Fronius unit ID en conflit", metadata.pluginId(), InverterSlaveId); storeConnection(GenericConnectionId, sunspecConnectionThingClassId, "SunSpec generique", metadata.pluginId(), 0); storeConnection(SolarEdgeConnectionId, solarEdgeConnectionThingClassId, "SolarEdge", metadata.pluginId(), 0); m_hardwareManager = new FakeHardwareManager(this); m_thingManager = new ThingManagerImplementation(m_hardwareManager, nullptr, QLocale(), this); m_plugin = new TestableSunSpecPlugin(); m_plugin->setMetaData(metadata); m_thingManager->registerStaticPlugin(m_plugin); QVERIFY2(!m_thingManager->findThingClass(froniusConnectionThingClassId).id().isNull(), "la thingClass froniusConnection n'est pas connue du ThingManager"); } void SunSpecMultiUnitTest::cleanupTestCase() { delete m_thingManager; m_thingManager = nullptr; } /* Le coeur de F3 : un seul thing, deux connexions. */ void SunSpecMultiUnitTest::test_F3_fronius_connection_creates_two_connections() { Thing *thing = awaitThing(FroniusWithMeterId); QVERIFY(thing); QVERIFY2(m_plugin->testPrimaryConnection(FroniusWithMeterId), "la connexion onduleur n'a pas ete creee"); QVERIFY2(m_plugin->testMeterConnection(FroniusWithMeterId), "la connexion compteur n'a pas ete creee alors que meterSlaveId vaut 200"); QVERIFY(m_plugin->testPrimaryConnection(FroniusWithMeterId) != m_plugin->testMeterConnection(FroniusWithMeterId)); } /* Deux connexions vers le meme host:port ne se distinguent que par l'unit ID. * S'il n'est pas repris du parametre, les deux interrogent l'onduleur. */ void SunSpecMultiUnitTest::test_F3_meter_connection_uses_the_meter_slave_id() { SunSpecConnection *primary = m_plugin->testPrimaryConnection(FroniusWithMeterId); SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId); QVERIFY(primary && meter); QCOMPARE(static_cast(primary->slaveId()), InverterSlaveId); QCOMPARE(static_cast(meter->slaveId()), MeterSlaveId); // Meme appareil, donc meme hote et meme port. QCOMPARE(meter->hostAddress(), primary->hostAddress()); QCOMPARE(meter->port(), primary->port()); } /* Q4 : la connexion compteur n'est montee qu'une fois la decouverte primaire * terminee. Deux decouvertes SunSpec simultanees seraient la charge la plus * lourde que ce plugin fasse jamais subir a un onduleur, et le cout de la * prudence n'est que de quelques secondes d'appairage. * * Ici le moniteur n'est jamais joignable, donc la primaire ne decouvre jamais : * la connexion compteur doit exister sans etre connectee. */ void SunSpecMultiUnitTest::test_F3_meter_connection_stays_idle_until_the_primary_discovered() { SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId); QVERIFY(meter); QVERIFY2(!meter->connected(), "la connexion compteur ne doit pas etre montee avant la fin de la decouverte primaire"); } /* 0 est la facon documentee de dire "pas de compteur derriere cet onduleur". */ void SunSpecMultiUnitTest::test_F3_meter_slave_id_zero_creates_only_one_connection() { Thing *thing = awaitThing(FroniusWithoutMeterId); QVERIFY(thing); QVERIFY(m_plugin->testPrimaryConnection(FroniusWithoutMeterId)); QVERIFY2(!m_plugin->testMeterConnection(FroniusWithoutMeterId), "meterSlaveId a 0 ne doit ouvrir aucune connexion compteur"); } /* Une seconde socket sur l'unit ID deja servi par l'onduleur redecouvrirait les * memes modeles et dupliquerait chaque thing enfant. */ void SunSpecMultiUnitTest::test_F3_meter_slave_id_colliding_with_the_inverter_is_refused() { Thing *thing = awaitThing(FroniusCollidingId); QVERIFY(thing); QVERIFY(m_plugin->testPrimaryConnection(FroniusCollidingId)); QVERIFY2(!m_plugin->testMeterConnection(FroniusCollidingId), "un meterSlaveId egal au slaveId de l'onduleur doit etre refuse"); } /* Non regression : le multi unit ID est une particularite Fronius. */ void SunSpecMultiUnitTest::test_F3_generic_connection_creates_no_meter_connection() { Thing *thing = awaitThing(GenericConnectionId); QVERIFY(thing); QVERIFY(m_plugin->testPrimaryConnection(GenericConnectionId)); QVERIFY2(!m_plugin->testMeterConnection(GenericConnectionId), "le vendor generique garde exactement une connexion par thing"); } /* Non regression du vendor solarEdge, celui dont aucune suite ne couvrait le * comportement. C'est la raison pour laquelle la connexion compteur vit dans * une table separee plutot que dans un QHash de listes : ainsi pas une seule * ligne de solarEdge n'a change, y compris la recherche inverse * m_sunSpecConnections.key() dont depend la decouverte des batteries. */ void SunSpecMultiUnitTest::test_F3_solaredge_connection_creates_no_meter_connection() { Thing *thing = awaitThing(SolarEdgeConnectionId); QVERIFY(thing); QVERIFY(m_plugin->testPrimaryConnection(SolarEdgeConnectionId)); QVERIFY2(!m_plugin->testMeterConnection(SolarEdgeConnectionId), "le vendor solarEdge garde exactement une connexion par thing"); } /* thingRemoved() est une chaine de else if dont la premiere branche portait sur * la seule connexion primaire. Sans reprise explicite, la connexion compteur * survit a son thing : socket ouverte et signaux pointant vers un Thing detruit. * * QSignalSpy sur destroyed() plutot qu'une inspection de la table : c'est la * duree de vie de l'objet qui est en cause, pas la propriete de la table. */ void SunSpecMultiUnitTest::test_F3_removal_destroys_both_connections() { SunSpecConnection *primary = m_plugin->testPrimaryConnection(FroniusWithMeterId); SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId); QVERIFY(primary && meter); QSignalSpy primaryDestroyed(primary, &QObject::destroyed); QSignalSpy meterDestroyed(meter, &QObject::destroyed); QCOMPARE(m_thingManager->removeConfiguredThing(FroniusWithMeterId), Thing::ThingErrorNoError); // Les deux sont detruites par deleteLater(), donc au prochain tour de boucle. QTRY_COMPARE_WITH_TIMEOUT(primaryDestroyed.count(), 1, 2000); QTRY_COMPARE_WITH_TIMEOUT(meterDestroyed.count(), 1, 2000); QVERIFY(!m_plugin->testPrimaryConnection(FroniusWithMeterId)); QVERIFY2(!m_plugin->testMeterConnection(FroniusWithMeterId), "la connexion compteur est restee dans la table apres la suppression du thing"); } QTEST_MAIN(SunSpecMultiUnitTest) #include "tst_sunspecmultiunit.moc"