diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 09771bd..df2ea08 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -251,9 +251,9 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) thing->thingClassId() == froniusConnectionThingClassId) { // Handle reconfigure - if (m_sunSpecConnections.contains(thing->id())) { + if (m_sunSpecConnections.contains(thing->id()) || m_meterConnections.contains(thing->id())) { qCDebug(dcSunSpec()) << "Reconfiguring existing thing" << thing->name(); - m_sunSpecConnections.take(thing->id())->deleteLater(); + destroyConnections(thing->id()); if (m_monitors.contains(thing)) { hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); @@ -290,6 +290,26 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) SunSpecConnection *connection = createConnection(info->thing(), slaveId, true); connect(info, &ThingSetupInfo::aborted, connection, &SunSpecConnection::deleteLater); + /* A Fronius GEN24 answers for its Smart Meter on another unit ID behind + * the same host and port. The connection is created here so that it is + * disposed of along with the thing, but stays idle: startMeterConnection() + * brings it up once the primary connection is done discovering. + * + * An absent meter never fails the setup. A GEN24 without a Smart Meter + * is a legitimate installation, and meterSlaveId has a non zero default + * the user did not choose. */ + SunSpecConnection *meterConnection = createMeterConnection(info->thing()); + if (meterConnection) { + /* Unlike the primary connection above, this one takes itself out of + * its hash on the way out, so that an aborted setup does not leave a + * dangling pointer behind. See D-4 in DIVERGENCES.md for why the + * primary path is left as it is. */ + connect(info, &ThingSetupInfo::aborted, meterConnection, [this, thing](){ + if (m_meterConnections.contains(thing->id())) + m_meterConnections.take(thing->id())->deleteLater(); + }); + } + // If this is the first setup, we must connect successfully fo finishing the setup, otherwise we let the monitor do his work if (info->isInitialSetup()) { @@ -348,16 +368,12 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) Thing *thing = info->thing(); uint modelId = thing->paramValue(m_modelIdParamTypeIds.value(thing->thingClassId())).toInt(); int modbusStartRegister = thing->paramValue(m_modbusAddressParamTypeIds.value(thing->thingClassId())).toInt(); - SunSpecConnection *connection = m_sunSpecConnections.value(thing->parentId()); - if (connection) { - // Get the model from the connection if already available - foreach (SunSpecModel *model, connection->models()) { - if (model->modelId() == modelId && model->modbusStartRegister() == modbusStartRegister) { - connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onInverterBlockUpdated); - m_sunSpecInverters.insert(thing, model); - qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing; - } - } + // Get the model from the connections of the parent if already available + SunSpecModel *model = findModelFor(thing->parentId(), modelId, modbusStartRegister); + if (model) { + connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onInverterBlockUpdated); + m_sunSpecInverters.insert(thing, model); + qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing; } info->finish(Thing::ThingErrorNoError); } else if (thing->thingClassId() == sunspecThreePhaseMeterThingClassId @@ -367,16 +383,13 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) Thing *thing = info->thing(); uint modelId = thing->paramValue(m_modelIdParamTypeIds.value(thing->thingClassId())).toInt(); int modbusStartRegister = thing->paramValue(m_modbusAddressParamTypeIds.value(thing->thingClassId())).toInt(); - SunSpecConnection *connection = m_sunSpecConnections.value(thing->parentId()); - if (connection) { - // Get the model from the connection - foreach (SunSpecModel *model, connection->models()) { - if (model->modelId() == modelId && model->modbusStartRegister() == modbusStartRegister) { - m_sunSpecMeters.insert(thing, model); - connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onMeterBlockUpdated); - qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing; - } - } + /* Searched across every connection of the parent: on a Fronius thing the + * meter answers on another unit ID than the inverter. */ + SunSpecModel *model = findModelFor(thing->parentId(), modelId, modbusStartRegister); + if (model) { + m_sunSpecMeters.insert(thing, model); + connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onMeterBlockUpdated); + qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing; } else { // Note: will be initialized once the connection is available and discovered. qCDebug(dcSunSpec()) << "Model not available yet for" << thing; @@ -387,16 +400,11 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) Thing *thing = info->thing(); uint modelId = thing->paramValue(m_modelIdParamTypeIds.value(thing->thingClassId())).toInt(); int modbusStartRegister = thing->paramValue(m_modbusAddressParamTypeIds.value(thing->thingClassId())).toInt(); - SunSpecConnection *connection = m_sunSpecConnections.value(thing->parentId()); - if (connection) { - // Get the model from the connection - foreach (SunSpecModel *model, connection->models()) { - if (model->modelId() == modelId && model->modbusStartRegister() == modbusStartRegister) { - m_sunSpecStorages.insert(thing, model); - connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated); - qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing; - } - } + SunSpecModel *model = findModelFor(thing->parentId(), modelId, modbusStartRegister); + if (model) { + m_sunSpecStorages.insert(thing, model); + connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated); + qCDebug(dcSunSpec()) << "Model initialized successfully for" << thing; } else { // Note: will be initialized once the connection is available and discovered. qCDebug(dcSunSpec()) << "Model not available yet for" << thing; @@ -442,8 +450,11 @@ void IntegrationPluginSunSpec::thingRemoved(Thing *thing) { qCDebug(dcSunSpec()) << "Thing removed" << thing->name(); - if (m_sunSpecConnections.contains(thing->id())) { - m_sunSpecConnections.take(thing->id())->deleteLater(); + /* A Fronius thing owns two connections. The chain below is an else if, so + * without taking both here the meter one would survive its thing, keeping + * its socket open and its signals pointing at a destroyed Thing. */ + if (m_sunSpecConnections.contains(thing->id()) || m_meterConnections.contains(thing->id())) { + destroyConnections(thing->id()); } else if (m_sunSpecThings.contains(thing)) { m_sunSpecThings.take(thing)->deleteLater(); } else if (m_sunSpecInverters.contains(thing)) { @@ -947,7 +958,7 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing, uint } }); - connect(connection, &SunSpecConnection::discoveryFinished, thing, [this, connection, thing] (bool success) { + connect(connection, &SunSpecConnection::discoveryFinished, thing, [this, connection, thing, isPrimary] (bool success) { if (success) { qCDebug(dcSunSpec()) << "Discovery finished successfully of" << connection; m_discoveredConnections.insert(connection); @@ -955,6 +966,14 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing, uint } else { qCWarning(dcSunSpec()) << "Discovery finished with errors on" << connection; } + + /* Whether it succeeded or not, the primary connection is done talking: + * this is the quietest moment to bring the Smart Meter one up. Two + * simultaneous SunSpec discoveries would be the heaviest load this + * plugin ever puts on an inverter, and the cost of avoiding it is a few + * seconds of pairing. */ + if (isPrimary) + startMeterConnection(thing); }); /* m_discoveredConnections is keyed on the pointer, so it must not outlive @@ -988,6 +1007,69 @@ QList IntegrationPluginSunSpec::allConnections() const return connections; } +SunSpecConnection *IntegrationPluginSunSpec::createMeterConnection(Thing *thing) +{ + if (thing->thingClassId() != froniusConnectionThingClassId) + return nullptr; + + const int inverterSlaveId = thing->paramValue(m_connectionSlaveIdParamTypeIds.value(thing->thingClassId())).toInt(); + const int meterSlaveId = thing->paramValue(froniusConnectionThingMeterSlaveIdParamTypeId).toInt(); + + switch (FroniusRules::decideMeterConnection(meterSlaveId, inverterSlaveId)) { + case FroniusRules::MeterConnectionDisabled: + qCInfo(dcSunSpec()) << "No Smart Meter configured on" << thing->name() + << "- the meter slave ID is 0."; + return nullptr; + case FroniusRules::MeterConnectionInvalidSlaveId: + qCWarning(dcSunSpec()) << "Not connecting a Smart Meter on" << thing->name() + << "- the meter slave ID" << meterSlaveId << "is out of range."; + return nullptr; + case FroniusRules::MeterConnectionCollidesWithInverter: + qCWarning(dcSunSpec()) << "Not connecting a Smart Meter on" << thing->name() + << "- the meter slave ID" << meterSlaveId + << "is the one the inverter already answers on. A second connection there" + << "would rediscover the very same models and duplicate every child thing."; + return nullptr; + case FroniusRules::MeterConnectionCreate: + break; + } + + qCDebug(dcSunSpec()) << "Creating the Smart Meter connection of" << thing->name() << "on slave ID" << meterSlaveId; + return createConnection(thing, meterSlaveId, false); +} + +void IntegrationPluginSunSpec::startMeterConnection(Thing *thing) +{ + SunSpecConnection *meterConnection = m_meterConnections.value(thing->id()); + if (!meterConnection) + return; + + if (meterConnection->connected()) { + /* Already up. Rediscover anyway, so that a meter added or removed while + * running is picked up, exactly like the primary connection does. */ + meterConnection->startDiscovery(); + return; + } + + NetworkDeviceMonitor *monitor = m_monitors.value(thing); + if (!monitor) + return; + + qCDebug(dcSunSpec()) << "Primary discovery of" << thing->name() << "is done, connecting the Smart Meter on slave ID" + << meterConnection->slaveId(); + meterConnection->setHostAddress(monitor->networkDeviceInfo().address()); + meterConnection->connectDevice(); +} + +void IntegrationPluginSunSpec::destroyConnections(const ThingId &thingId) +{ + if (m_sunSpecConnections.contains(thingId)) + m_sunSpecConnections.take(thingId)->deleteLater(); + + if (m_meterConnections.contains(thingId)) + m_meterConnections.take(thingId)->deleteLater(); +} + SunSpecConnection *IntegrationPluginSunSpec::connectionForChild(Thing *child) const { if (m_sunSpecInverters.contains(child)) @@ -1005,6 +1087,18 @@ SunSpecConnection *IntegrationPluginSunSpec::connectionForChild(Thing *child) co return nullptr; } +SunSpecModel *IntegrationPluginSunSpec::findModelFor(const ThingId &parentId, uint modelId, int modbusStartRegister) const +{ + foreach (SunSpecConnection *connection, connectionsFor(parentId)) { + foreach (SunSpecModel *model, connection->models()) { + if (model->modelId() == modelId && model->modbusStartRegister() == modbusStartRegister) + return model; + } + } + + return nullptr; +} + QList IntegrationPluginSunSpec::childThingsOn(Thing *parent, SunSpecConnection *connection) const { const bool isPrimary = (m_sunSpecConnections.value(parent->id()) == connection); diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 1a75316..9c60331 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -56,6 +56,17 @@ public: void thingRemoved(Thing *thing) override; void executeAction(ThingActionInfo *info) override; +#ifdef SUNSPEC_PLUGIN_TEST_ACCESS + /* Test only, compiled in by sunspec/tests/ and by nothing else. + * + * Whether a thing ended up with one connection or two is not observable + * from the outside: both connections sit behind the same network monitor + * and the same host and port. Without this, the multi unit ID behaviour + * could only be checked against a real device. */ + SunSpecConnection *testPrimaryConnection(const ThingId &parentId) const { return m_sunSpecConnections.value(parentId); } + SunSpecConnection *testMeterConnection(const ThingId &parentId) const { return m_meterConnections.value(parentId); } +#endif + private: // SunSpec Connection params map QHash m_connectionDiscoverySlaveIdParamTypeIds; @@ -122,6 +133,10 @@ private: * announce the same model. */ SunSpecConnection *connectionForChild(Thing *child) const; + /* The model a child thing is described by, looked up across every + * connection of its parent. */ + SunSpecModel *findModelFor(const ThingId &parentId, uint modelId, int modbusStartRegister) const; + /* Children of a parent that read from one given connection. A child not * bound to any model yet follows the primary connection, which is what * makes the parent reachable at all. */ @@ -150,6 +165,18 @@ private: bool isFroniusChild(const ThingId &parentId); int effectiveUpdateInterval(); + /* Creates the Smart Meter connection of a Fronius thing, or returns null + * and says why. The object is created here but left idle: it is connected + * only once the primary connection is done discovering, so that the two + * never handshake at the same time towards the same inverter. */ + SunSpecConnection *createMeterConnection(Thing *thing); + void startMeterConnection(Thing *thing); + + /* Destroys every connection of a thing. A single entry point, so that the + * meter connection cannot be forgotten on the paths that used to have only + * one connection to dispose of. */ + void destroyConnections(const ThingId &thingId); + // Storage setpoints void executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan); void executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index); diff --git a/sunspec/tests/multiunit/multiunit.pro b/sunspec/tests/multiunit/multiunit.pro new file mode 100644 index 0000000..65b082c --- /dev/null +++ b/sunspec/tests/multiunit/multiunit.pro @@ -0,0 +1,64 @@ +include(../../../sunspec.pri) + +QT += testlib network serialbus +QT -= gui +CONFIG += console testcase c++17 +CONFIG -= app_bundle +TEMPLATE = app +TARGET = tst_sunspecmultiunit + +# Suite F3 : un thing froniusConnection porte deux SunSpecConnection, une par +# unit ID, sur le meme host:port. +# +# Binaire separe de ../plugin plutot que tests ajoutes a celui-la : les things +# deja configures sont ecrits dans les reglages avant le demarrage du +# ThingManager, donc en ajouter ferait mentir les compteurs d'appels que la +# suite F1 verifie. Chaque binaire a son propre nom d'organisation, donc son +# propre fichier de reglages, et n'interfere pas avec l'autre. + +CONFIG += link_pkgconfig +PKGCONFIG += nymea nymea-core + +PLUGIN_DIR = $$PWD/../.. +INCLUDEPATH += $$PLUGIN_DIR + +# Accesseurs reserves aux tests : voir integrationpluginsunspec.h. Sans eux, le +# nombre de connexions d'un thing n'est pas observable, les deux vivant derriere +# le meme moniteur reseau et le meme host:port. +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_sunspecmultiunit.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/multiunit/tst_sunspecmultiunit.cpp b/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp new file mode 100644 index 0000000..0508d67 --- /dev/null +++ b/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp @@ -0,0 +1,427 @@ +// 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_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 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 { + 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); + + 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"); +} + +/* 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" diff --git a/sunspec/tests/tests.pro b/sunspec/tests/tests.pro index c944294..7c745ad 100644 --- a/sunspec/tests/tests.pro +++ b/sunspec/tests/tests.pro @@ -5,6 +5,11 @@ TEMPLATE = subdirs # ThingManager et un HardwareManager doubles. Demande en plus # libnymea1-dev et libnymea-core (pkg-config nymea, nymea-core). +# multiunit : meme montage que plugin, mais pour un thing froniusConnection +# portant deux SunSpecConnection (F3). Binaire distinct pour ne pas +# fausser les compteurs d'appels globaux verifies par plugin. + SUBDIRS += \ logic \ - plugin + plugin \ + multiunit