// SPDX-License-Identifier: GPL-3.0-or-later
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright (C) 2026, ETM-PowerSync
*
* This file is part of nymea.
*
* nymea is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or (at your option) any later
* version.
*
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* nymea. If not, see .
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* F3 face a un vrai appareil.
*
* Les deux defauts les plus certains du multi unit ID sont invisibles pour les
* suites hors ligne, parce qu'ils n'apparaissent qu'une fois la decouverte
* reellement passee :
*
* - le balayage des orphelins, qui sur une seule des deux connexions ferait
* disparaitre les enfants de l'autre a chaque decouverte ;
* - l'agregation de l'etat connected entre les deux connexions.
*
* Cette suite n'est pas lancee par "make check" : elle exige un appareil. Voir
* integration.pro pour les variables d'environnement. Sans SUNSPEC_TEST_HOST,
* tout se met en QSKIP.
*
* Aucune adresse par defaut n'est ecrite ici. L'adresse d'une installation
* reelle est une donnee client (AGENTS.md, "Miroir public").
*/
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "integrationpluginsunspec.h"
/* --------------------------------------------------------------------------
* Doublures
* -------------------------------------------------------------------------- */
/* Contrairement aux suites hors ligne, le moniteur porte ici une vraie adresse
* et se declare joignable : c'est ce qui autorise le plugin a se connecter. */
class DeviceMonitor : public NetworkDeviceMonitor
{
public:
DeviceMonitor(const QHostAddress &address, QObject *parent = nullptr)
: NetworkDeviceMonitor(parent), m_address(address)
{
m_info = NetworkDeviceInfo(address);
m_info.setMonitorMode(NetworkDeviceInfo::MonitorModeIp);
}
MacAddress macAddress() const override { return MacAddress(); }
QString hostName() const override { return QString(); }
QHostAddress address() const override { return m_address; }
NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; }
NetworkDeviceInfo networkDeviceInfo() const override { return m_info; }
bool reachable() const override { return true; }
QDateTime lastSeen() const override { return QDateTime::currentDateTime(); }
uint pingRetries() const override { return 0; }
void setPingRetries(uint) override {}
private:
QHostAddress m_address;
NetworkDeviceInfo m_info;
};
class DeviceDiscovery : public NetworkDeviceDiscovery
{
public:
DeviceDiscovery(const QHostAddress &address, QObject *parent = nullptr)
: NetworkDeviceDiscovery(parent), m_address(address) {}
NetworkDeviceDiscoveryReply *discover() override { return nullptr; }
bool running() const override { return false; }
NetworkDeviceMonitor *registerMonitor(Thing *) override { return new DeviceMonitor(m_address, this); }
void unregisterMonitor(NetworkDeviceMonitor *monitor) override { delete monitor; }
PingReply *ping(const QHostAddress &, uint) override { return nullptr; }
PingReply *ping(const QString &, uint) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; }
MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; }
bool sendArpRequest(const QHostAddress &) override { return false; }
NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
private:
QHostAddress m_address;
};
class NullPluginTimer : public PluginTimer
{
public:
explicit NullPluginTimer(int seconds, QObject *parent = nullptr)
: PluginTimer(parent), m_interval(seconds) {}
int interval() const override { return m_interval; }
int currentTick() const override { return 0; }
bool running() const override { return m_running; }
void reset() override {}
void start() override { m_running = true; }
void stop() override { m_running = false; }
void pause() override {}
void resume() override {}
private:
int m_interval = 0;
bool m_running = false;
};
/* Le minuteur n'est jamais declenche par cette suite. Interroger en boucle un
* onduleur client est precisement ce que le plancher de cadence cherche a
* eviter ; ici on ne veut que la decouverte. */
class NullPluginTimerManager : public PluginTimerManager
{
public:
explicit NullPluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {}
PluginTimer *registerTimer(int seconds) override { return new NullPluginTimer(seconds, this); }
void unregisterTimer(PluginTimer *timer) override { delete timer; }
bool available() const override { return true; }
bool enabled() const override { return true; }
protected:
void setEnabled(bool) override {}
};
/* Les things enfants sont crees par le plugin, pas ecrits d'avance dans les
* reglages : on ne peut donc pas leur retirer leurs states journalises comme le
* font les suites hors ligne. Il faut un LogEngine, meme creux, sans quoi le
* ThingManager en dereference un absent des la creation du premier enfant. */
class NullLogEngine : public LogEngine
{
public:
explicit NullLogEngine(QObject *parent = nullptr) : LogEngine(parent) {}
Logger *registerLogSource(const QString &name, const QStringList &tags,
Types::LoggingType loggingType, const QString &) override
{
Logger *logger = createLogger(name, tags, loggingType);
m_loggers.insert(name, logger);
return logger;
}
void unregisterLogSource(const QString &name) override { m_loggers.remove(name); }
LogFetchJob *fetchLogEntries(const QStringList &, const QStringList &, const QDateTime &,
const QDateTime &, const QVariantMap &, Types::SampleRate,
Qt::SortOrder, int, int) override
{
LogFetchJob *job = new LogFetchJob(this);
finishFetchJob(job, LogEntries());
return job;
}
bool jobsRunning() const override { return false; }
void clear(const QString &) override {}
void enable() override {}
void disable() override {}
private:
void logEvent(Logger *, const QStringList &, const QVariantMap &) override {}
QHash m_loggers;
};
class DeviceHardwareManager : public HardwareManager
{
public:
DeviceHardwareManager(const QHostAddress &address, QObject *parent = nullptr)
: HardwareManager(parent)
, m_pluginTimerManager(new NullPluginTimerManager(this))
, m_networkDeviceDiscovery(new DeviceDiscovery(address, this))
{}
Radio433 *radio433() override { return nullptr; }
PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; }
NetworkAccessManager *networkManager() override { return nullptr; }
UpnpDiscovery *upnpDiscovery() override { return nullptr; }
PlatformZeroConfController *zeroConfController() override { return nullptr; }
BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; }
MqttProvider *mqttProvider() override { return nullptr; }
I2CManager *i2cManager() override { return nullptr; }
ZigbeeHardwareResource *zigbeeResource() override { return nullptr; }
ZWaveHardwareResource *zwaveResource() override { return nullptr; }
ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; }
NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; }
private:
NullPluginTimerManager *m_pluginTimerManager = nullptr;
DeviceDiscovery *m_networkDeviceDiscovery = nullptr;
};
class TestableSunSpecPlugin : public IntegrationPluginSunSpec
{
public:
using IntegrationPlugin::setMetaData;
};
/* -------------------------------------------------------------------------- */
class SunSpecDeviceTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase();
void cleanupTestCase();
void test_F3_both_connections_reach_the_device();
void test_F3_meter_connects_only_after_the_primary_discovery();
void test_F3_inverter_and_meter_children_share_one_parent();
void test_F3_no_child_disappears_across_repeated_discoveries();
void test_F3_meter_slave_id_zero_opens_no_second_connection();
void test_F3_losing_the_meter_leaves_the_inverter_alone();
private:
QTemporaryDir m_pluginSearchDir;
QHostAddress m_address;
int m_port = 502;
int m_slaveId = 1;
int m_meterSlaveId = 200;
DeviceHardwareManager *m_hardwareManager = nullptr;
ThingManagerImplementation *m_thingManager = nullptr;
NullLogEngine *m_logEngine = nullptr;
TestableSunSpecPlugin *m_plugin = nullptr;
bool m_configured = false;
/* Ces deux temoins sont ecrits par des lambdas connectees dans
* initTestCase() et relues par un test ulterieur : ce sont donc des membres,
* jamais des locales capturees par reference. Une locale de initTestCase()
* capturee par reference serait relachee au retour de la fonction, et les
* lambdas ecriraient ensuite dans une case de pile reutilisee par les frames
* suivantes - corruption silencieuse du test lui-meme. */
bool m_primaryDiscovered = false;
/* Vrai des que la connexion compteur signale connected, pour verifier que
* cela n'arrive jamais avant la fin de la decouverte primaire. */
bool m_meterConnectedBeforePrimaryDiscovery = false;
bool skipUnlessConfigured();
QList childrenOfKind(const ThingClassId &thingClassId) const;
void storeConnection(const ThingId &thingId, const QString &name,
const PluginId &pluginId, int meterSlaveId);
SunSpecModel *modelOn(SunSpecConnection *connection, quint16 modelId) const;
};
static const ThingId DeviceThingId = ThingId("5d3f8a26-41bc-4e79-a05f-9b7c2e6d1483");
static const ThingId DeviceNoMeterThingId = ThingId("6a4e1b07-52d8-4c93-8e7b-1f39c04a5d62");
void SunSpecDeviceTest::storeConnection(const ThingId &thingId, const QString &name,
const PluginId &pluginId, int meterSlaveId)
{
NymeaSettings settings(NymeaSettings::SettingsRoleThings);
settings.beginGroup("ThingConfig");
settings.beginGroup(thingId.toString());
settings.setValue("thingName", name);
settings.setValue("pluginid", pluginId.toString());
settings.setValue("thingClassId", froniusConnectionThingClassId.toString());
settings.beginGroup("Params");
settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), m_address.toString());
settings.setValue(froniusConnectionThingPortParamTypeId.toString(), m_port);
settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), m_slaveId);
settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId);
settings.endGroup();
settings.endGroup();
settings.endGroup();
settings.sync();
}
bool SunSpecDeviceTest::skipUnlessConfigured()
{
if (m_configured)
return false;
/* QSKIP ne peut pas etre appele depuis une fonction auxiliaire, donc le
* message est laisse a l'appelant. */
return true;
}
QList SunSpecDeviceTest::childrenOfKind(const ThingClassId &thingClassId) const
{
QList found;
foreach (Thing *thing, m_thingManager->configuredThings()) {
if (thing->thingClassId() == thingClassId && thing->parentId() == DeviceThingId)
found.append(thing);
}
return found;
}
void SunSpecDeviceTest::initTestCase()
{
const QByteArray host = qgetenv("SUNSPEC_TEST_HOST");
if (host.isEmpty()) {
qInfo() << "SUNSPEC_TEST_HOST is not set: every test will be skipped.";
return;
}
m_address = QHostAddress(QString::fromUtf8(host));
QVERIFY2(!m_address.isNull(), "SUNSPEC_TEST_HOST is not a valid address");
if (!qgetenv("SUNSPEC_TEST_PORT").isEmpty())
m_port = qgetenv("SUNSPEC_TEST_PORT").toInt();
if (!qgetenv("SUNSPEC_TEST_SLAVE_ID").isEmpty())
m_slaveId = qgetenv("SUNSPEC_TEST_SLAVE_ID").toInt();
if (!qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").isEmpty())
m_meterSlaveId = qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").toInt();
QCoreApplication::setOrganizationName("nymea-test-sunspec-device");
NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings);
thingSettings.clear();
NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins);
pluginSettings.clear();
QVERIFY(m_pluginSearchDir.isValid());
qputenv("NYMEA_PLUGINS_PATH", m_pluginSearchDir.path().toUtf8());
QFile jsonFile(PLUGIN_JSON_PATH);
QVERIFY2(jsonFile.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH)));
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(jsonFile.readAll(), &error);
QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString()));
const PluginMetadata metadata(document.object());
QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide");
storeConnection(DeviceThingId, "Fronius", metadata.pluginId(), m_meterSlaveId);
/* Le cas degenere documente au contrat : 0 = pas de compteur derriere cet
* onduleur. Meme appareil, meme unit ID onduleur, mais aucune seconde
* connexion ne doit etre ouverte. */
storeConnection(DeviceNoMeterThingId, "Fronius sans compteur", metadata.pluginId(), 0);
m_hardwareManager = new DeviceHardwareManager(m_address, this);
m_logEngine = new NullLogEngine(this);
m_thingManager = new ThingManagerImplementation(m_hardwareManager, m_logEngine, QLocale(), this);
m_plugin = new TestableSunSpecPlugin();
m_plugin->setMetaData(metadata);
m_thingManager->registerStaticPlugin(m_plugin);
Thing *thing = nullptr;
QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceThingId)) != nullptr, 5000);
QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000);
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
QVERIFY(primary);
QVERIFY2(meter, "aucune connexion compteur creee : verifier SUNSPEC_TEST_METER_SLAVE_ID");
/* Arme avant toute attente, pour que l'ordre des deux evenements soit
* observe et pas reconstitue apres coup. */
connect(primary, &SunSpecConnection::discoveryFinished, this, [this](bool success) {
if (success)
m_primaryDiscovered = true;
});
connect(meter, &SunSpecConnection::connectedChanged, this, [this](bool connected) {
if (connected && !m_primaryDiscovered)
m_meterConnectedBeforePrimaryDiscovery = true;
});
m_configured = true;
}
void SunSpecDeviceTest::cleanupTestCase()
{
delete m_thingManager;
m_thingManager = nullptr;
}
void SunSpecDeviceTest::test_F3_both_connections_reach_the_device()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
QVERIFY(primary && meter);
QTRY_VERIFY_WITH_TIMEOUT(primary->connected(), 30000);
QTRY_VERIFY_WITH_TIMEOUT(meter->connected(), 60000);
QCOMPARE(static_cast(primary->slaveId()), m_slaveId);
QCOMPARE(static_cast(meter->slaveId()), m_meterSlaveId);
// Les deux ont trouve un registre de base SunSpec, chacune sur son unit ID.
QTRY_VERIFY_WITH_TIMEOUT(!primary->models().isEmpty(), 60000);
QTRY_VERIFY_WITH_TIMEOUT(!meter->models().isEmpty(), 60000);
}
/* Q4. Deux decouvertes SunSpec simultanees seraient la charge la plus lourde
* que ce plugin fasse jamais subir a un onduleur. */
void SunSpecDeviceTest::test_F3_meter_connects_only_after_the_primary_discovery()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
QVERIFY2(!m_meterConnectedBeforePrimaryDiscovery,
"la connexion compteur est montee avant la fin de la decouverte primaire");
}
/* Un seul appareil a appairer, tous les enfants sous le meme parent. */
void SunSpecDeviceTest::test_F3_inverter_and_meter_children_share_one_parent()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseInverterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSplitPhaseInverterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSinglePhaseInverterThingClassId).isEmpty(), 60000);
QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseMeterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSplitPhaseMeterThingClassId).isEmpty()
|| !childrenOfKind(sunspecSinglePhaseMeterThingClassId).isEmpty(), 60000);
}
/* Le defaut le plus certain du multi unit ID : chaque connexion ne voit que la
* moitie du tableau, donc un balayage des orphelins fait sur cette moitie ferait
* disparaitre les enfants de l'autre. Une redecouverte suffit a le declencher. */
void SunSpecDeviceTest::test_F3_no_child_disappears_across_repeated_discoveries()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
const int childrenBefore = m_thingManager->configuredThings().filterByParentId(DeviceThingId).count();
QVERIFY2(childrenBefore > 0, "aucun enfant a surveiller");
QSignalSpy disappeared(m_plugin, &IntegrationPlugin::autoThingDisappeared);
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
for (int round = 0; round < 2; round++) {
QSignalSpy primaryDone(primary, &SunSpecConnection::discoveryFinished);
primary->startDiscovery();
QVERIFY2(primaryDone.wait(60000), "la decouverte primaire n'a pas rendu la main");
QSignalSpy meterDone(meter, &SunSpecConnection::discoveryFinished);
meter->startDiscovery();
QVERIFY2(meterDone.wait(60000), "la decouverte compteur n'a pas rendu la main");
}
QCOMPARE(disappeared.count(), 0);
QCOMPARE(m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(), childrenBefore);
}
SunSpecModel *SunSpecDeviceTest::modelOn(SunSpecConnection *connection, quint16 modelId) const
{
foreach (SunSpecModel *model, connection->models()) {
if (model->modelId() == modelId)
return model;
}
return nullptr;
}
/* Le cas degenere : meterSlaveId a 0 veut dire "pas de compteur derriere cet
* onduleur". Meme appareil, meme unit ID onduleur, mais une seule connexion.
*
* On n'affirme rien sur les enfants de ce second thing : le premier a deja cree
* un enfant onduleur portant le numero de serie du simulateur, et la
* deduplication par numero de serie de autocreateSunSpecModelThing() est
* globale, pas par parent. C'est la connexion qui est mesuree ici. */
void SunSpecDeviceTest::test_F3_meter_slave_id_zero_opens_no_second_connection()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
Thing *thing = nullptr;
QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceNoMeterThingId)) != nullptr, 5000);
QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000);
QVERIFY2(m_plugin->testPrimaryConnection(DeviceNoMeterThingId),
"la connexion onduleur n'a pas ete creee");
QVERIFY2(!m_plugin->testMeterConnection(DeviceNoMeterThingId),
"meterSlaveId a 0 ne doit ouvrir aucune connexion compteur");
}
/* Le second defaut invisible hors ligne : l'etat connected du parent suit la
* connexion primaire seule. Un Smart Meter qui tombe ne doit ni marquer
* l'onduleur injoignable, ni effacer les donnees qu'il continue de livrer.
*
* Place en dernier, parce qu'il coupe une connexion pour de bon. */
void SunSpecDeviceTest::test_F3_losing_the_meter_leaves_the_inverter_alone()
{
if (skipUnlessConfigured())
QSKIP("SUNSPEC_TEST_HOST is not set");
Thing *parent = m_thingManager->findConfiguredThing(DeviceThingId);
QVERIFY(parent);
QVERIFY2(parent->stateValue("connected").toBool(), "le parent n'etait pas connecte au depart");
QList inverters = childrenOfKind(sunspecThreePhaseInverterThingClassId);
QList meters = childrenOfKind(sunspecThreePhaseMeterThingClassId);
QVERIFY2(!inverters.isEmpty(), "pas d'enfant onduleur a surveiller");
QVERIFY2(!meters.isEmpty(), "pas d'enfant compteur a couper");
Thing *inverterThing = inverters.first();
Thing *meterThing = meters.first();
SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId);
SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId);
QVERIFY(primary && meter);
SunSpecModel *inverterModel = modelOn(primary, 113);
SunSpecModel *meterModel = modelOn(meter, 213);
QVERIFY2(inverterModel, "modele onduleur 113 introuvable sur la connexion primaire");
QVERIFY2(meterModel, "modele compteur 213 introuvable sur la connexion compteur");
/* Un tour d'interrogation explicite. Ce banc remplace le minuteur du plugin
* par un objet creux - on ne veut pas interroger un appareil en boucle - or
* c'est la mise a jour de bloc qui met un enfant a connected. Sans ce tour,
* les enfants n'auraient jamais ete connectes et la coupure ci-dessous ne
* prouverait rien. */
QSignalSpy inverterFirst(inverterModel, &SunSpecModel::blockUpdated);
QSignalSpy meterFirst(meterModel, &SunSpecModel::blockUpdated);
inverterModel->readBlockData();
meterModel->readBlockData();
QVERIFY2(inverterFirst.wait(30000), "pas de donnee onduleur avant la coupure");
if (meterFirst.isEmpty())
QVERIFY2(meterFirst.wait(30000), "pas de donnee compteur avant la coupure");
QVERIFY2(inverterThing->stateValue("connected").toBool(),
"l'enfant onduleur n'etait pas connecte avant la coupure");
QVERIFY2(meterThing->stateValue("connected").toBool(),
"l'enfant compteur n'etait pas connecte avant la coupure");
// Coupure franche de la seule connexion compteur.
meter->disconnectDevice();
QTRY_VERIFY_WITH_TIMEOUT(!meter->connected(), 15000);
// Le parent reste connecte : c'est la primaire qui fait foi, jamais un ET logique.
QVERIFY2(parent->stateValue("connected").toBool(),
"le parent est passe deconnecte alors que seule la connexion compteur est tombee");
QVERIFY(primary->connected());
// L'enfant onduleur n'est pas affecte...
QVERIFY2(inverterThing->stateValue("connected").toBool(),
"l'enfant onduleur a ete marque deconnecte par la chute du compteur");
QVERIFY2(inverterThing->stateValue("currentPower").isValid(),
"les donnees onduleur ont ete effacees");
// ...et ses donnees continuent d'arriver.
QSignalSpy inverterUpdated(inverterModel, &SunSpecModel::blockUpdated);
inverterModel->readBlockData();
QVERIFY2(inverterUpdated.wait(30000),
"plus aucune donnee onduleur apres la chute du compteur");
// Seul l'enfant compteur est marque deconnecte.
QVERIFY2(!meterThing->stateValue("connected").toBool(),
"l'enfant compteur aurait du etre marque deconnecte");
}
QTEST_MAIN(SunSpecDeviceTest)
#include "tst_sunspecdevice.moc"