From a59854471244f2ee39a08faaddae5027ed140be8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:00:27 +0200 Subject: [PATCH] sunspec: open a second connection for the Fronius Smart Meter A GEN24 answers for the inverter on unit ID 1 and for its Smart Meter on unit ID 200 by default, behind the same host and port. One thing now carries two SunSpecConnection objects, the second one driven by the meterSlaveId parameter that until now no line of C++ ever read. The meter connection is created during setup but left idle: it is only brought up once the primary connection has finished discovering, so the two never handshake at the same time towards the same inverter. An absent meter never fails the pairing, since a GEN24 without a Smart Meter is a legitimate installation and the parameter has a non zero default the user did not choose. thingRemoved() and the reconfigure path now dispose of both connections through a single entry point. Their else if chain used to stop at the primary one, which left the meter connection outliving its thing with an open socket and signals pointing at a destroyed Thing. Child things look their model up across every connection of their parent. The two connections of a GEN24 never announce the same model ID, so no discriminator has to be persisted. Adds a test suite instantiating the plugin behind a real ThingManager with two connections. It lives in its own binary because the F1 suite checks global call counters that extra configured things would falsify. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/integrationpluginsunspec.cpp | 164 +++++-- sunspec/integrationpluginsunspec.h | 27 ++ sunspec/tests/multiunit/multiunit.pro | 64 +++ .../tests/multiunit/tst_sunspecmultiunit.cpp | 427 ++++++++++++++++++ sunspec/tests/tests.pro | 7 +- 5 files changed, 653 insertions(+), 36 deletions(-) create mode 100644 sunspec/tests/multiunit/multiunit.pro create mode 100644 sunspec/tests/multiunit/tst_sunspecmultiunit.cpp 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