Patrick Schurig a598544712 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) <noreply@anthropic.com>
2026-08-02 09:00:27 +02:00

428 lines
18 KiB
C++

// 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 <https://www.gnu.org/licenses/>.
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* 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 <QtTest>
#include <QCoreApplication>
#include <QTemporaryDir>
#include <nymeasettings.h>
#include <hardwaremanager.h>
#include <plugintimer.h>
#include <network/networkdevicediscovery.h>
#include <network/networkdevicemonitor.h>
#include <integrations/thingmanagerimplementation.h>
#include <integrations/thingsetupinfo.h>
#include <sunspecconnection.h>
#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<int>(primary->slaveId()), InverterSlaveId);
QCOMPARE(static_cast<int>(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"