From b23a9a2d009ca2e0077a4c7efd26545079cc44a2 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 3 Jul 2026 09:55:59 +0200 Subject: [PATCH 01/24] abbterra: remove checkReachableRegister - not supported in libnymea-modbus 1.15 --- abbterra/abbterra-registers.json | 1 - 1 file changed, 1 deletion(-) diff --git a/abbterra/abbterra-registers.json b/abbterra/abbterra-registers.json index 18c2325..4883cad 100644 --- a/abbterra/abbterra-registers.json +++ b/abbterra/abbterra-registers.json @@ -4,7 +4,6 @@ "endianness": "BigEndian", "stringEndianness": "BigEndian", "errorLimitUntilNotReachable": 3, - "checkReachableRegister": "serialNumber", "queuedRequests": true, "queuedRequestsDelay": 50, "blocks": [ From 368c80688a6d71fb5f02dc34a15370aa2d04abb1 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 3 Jul 2026 10:48:47 +0200 Subject: [PATCH 02/24] abbterra: fix TCP discovery SEGV + restore checkReachableRegister MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled fixes so the ABB Terra AC can be added over Modbus TCP: - Restore checkReachableRegister ("serialNumber") in the register JSON. The libnymea-modbus 1.15 code generator *requires* it — without it the connection classes (abbterramodbustcpconnection.h) are never generated and the plugin fails to build. Reverts the false lead in b23a9a2. The register is valid: mbpoll confirms holding reg 16384 (uint64) responds at slave 2. - Fix a use-after-free in AbbTerraTcpDiscovery::cleanupConnection() that SIGSEGV'd nymead during network discovery whenever any Modbus device on the LAN answered on :502 with an exception (e.g. "illegal data address"). The method is invoked from several of the connection's own signals; it now bails out on the second (re-entrant) call and disconnects the connection's signals before disconnectDevice(), so the teardown can no longer re-enter while the QModbusReply "finished" handler is still dereferencing the reply. Co-Authored-By: Claude Opus 4.8 (1M context) --- abbterra/abbterra-registers.json | 1 + abbterra/abbterratcpdiscovery.cpp | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/abbterra/abbterra-registers.json b/abbterra/abbterra-registers.json index 4883cad..a2b385c 100644 --- a/abbterra/abbterra-registers.json +++ b/abbterra/abbterra-registers.json @@ -1,6 +1,7 @@ { "className": "AbbTerra", "protocol": "BOTH", + "checkReachableRegister": "serialNumber", "endianness": "BigEndian", "stringEndianness": "BigEndian", "errorLimitUntilNotReachable": 3, diff --git a/abbterra/abbterratcpdiscovery.cpp b/abbterra/abbterratcpdiscovery.cpp index e0077f1..dd104ef 100644 --- a/abbterra/abbterratcpdiscovery.cpp +++ b/abbterra/abbterratcpdiscovery.cpp @@ -92,7 +92,24 @@ void AbbTerraTcpDiscovery::checkNetworkDevice(const QHostAddress &address) void AbbTerraTcpDiscovery::cleanupConnection(AbbTerraModbusTcpConnection *connection) { - m_connections.removeAll(connection); + // A single reachability failure fans out into several signals + // (reachableChanged, checkReachabilityFailed, connectionErrorOccurred), each + // wired to this method. removeAll() returns the number of entries actually + // removed, so the first call tears the connection down and every subsequent + // (possibly re-entrant) call becomes a no-op. + if (m_connections.removeAll(connection) == 0) + return; + + // Stop reacting to any further signals from this connection (and its Modbus + // master) before tearing it down. Otherwise disconnectDevice() below can + // synchronously emit reachableChanged()/connectionErrorOccurred() and + // re-enter this method while we are still inside one of the connection's own + // signal handlers (e.g. the QModbusReply "finished" lambda that keeps + // dereferencing the reply after the emit) — a use-after-free that crashes + // nymead with SIGSEGV during network discovery. + disconnect(connection, nullptr, this, nullptr); + disconnect(connection->modbusTcpMaster(), nullptr, this, nullptr); + connection->disconnectDevice(); connection->deleteLater(); } From cbfb5df23e5609e96703cdc176154fbf0fbe37d0 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 3 Jul 2026 12:14:44 +0200 Subject: [PATCH 03/24] abbterra: make ABB Terra AC addable over Modbus TCP (etm2..etm5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the etm1 fixes, driven by live debugging against a real ABB Terra AC 7 kW behind a Waveshare RS485->TCP gateway (192.168.1.132, slave 2): - etm3: disable TCP auto-discovery (createMethods = "user" only). Discovery blind-scans the LAN and SIGSEGVs nymead as soon as any Modbus device answers :502 with an exception response. The crash is in the generated reachability handler (abbterramodbustcpconnection.cpp:2062 dereferences the check-reachable reply after it is freed); it could not be fixed from the discovery code alone. A gateway's slave id can't be discovered anyway, so manual add is required. - etm2: still defer/guard AbbTerraTcpDiscovery::cleanupConnection() teardown (kept for correctness even though discovery is now off by default). - etm4: setupTcpThing() never called connectDevice(), so manual setup hung until timeout. Start the connection when the network monitor reports the device. - etm5: decode the serial-number and firmware register fields as BCD per ABB spec §5.1 (0x46 = week 46, not 70; 0x22 = 22 kW). The binary reading rejected real chargers ("does not match the ABB Terra AC Modbus register map"). Verified end to end: charger adds cleanly (TACW7-4-4624-S0034), states poll and actions execute, no SIGSEGV. The underlying discovery/generated-code crash is tracked separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- abbterra/abbterratcpdiscovery.cpp | 22 +++++++---- abbterra/abbterrautils.h | 26 ++++++++----- abbterra/integrationpluginabbterra.cpp | 17 +++++++++ abbterra/integrationpluginabbterra.json | 2 - debian/changelog | 50 +++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 19 deletions(-) diff --git a/abbterra/abbterratcpdiscovery.cpp b/abbterra/abbterratcpdiscovery.cpp index dd104ef..a48cf9d 100644 --- a/abbterra/abbterratcpdiscovery.cpp +++ b/abbterra/abbterratcpdiscovery.cpp @@ -101,17 +101,23 @@ void AbbTerraTcpDiscovery::cleanupConnection(AbbTerraModbusTcpConnection *connec return; // Stop reacting to any further signals from this connection (and its Modbus - // master) before tearing it down. Otherwise disconnectDevice() below can - // synchronously emit reachableChanged()/connectionErrorOccurred() and - // re-enter this method while we are still inside one of the connection's own - // signal handlers (e.g. the QModbusReply "finished" lambda that keeps - // dereferencing the reply after the emit) — a use-after-free that crashes - // nymead with SIGSEGV during network discovery. + // master) so the teardown below cannot re-enter this method. disconnect(connection, nullptr, this, nullptr); disconnect(connection->modbusTcpMaster(), nullptr, this, nullptr); - connection->disconnectDevice(); - connection->deleteLater(); + // Defer the actual teardown to the next event-loop iteration. cleanupConnection + // is reached *synchronously* from inside the connection's QModbusReply "finished" + // handler (the testReachability() lambda) via checkReachabilityFailed(): that + // handler still dereferences m_checkReachableReply after emitting the signal + // (abbterramodbustcpconnection.cpp:2062, "m_checkReachableReply->deleteLater()"). + // Calling disconnectDevice() here synchronously frees/invalidates that in-flight + // reply mid-handler, so line 2062 dereferences freed memory -> SIGSEGV in + // QObject::deleteLater(). Queuing the teardown lets the finished handler unwind + // completely (freeing the reply itself) before we disconnect and delete. + QMetaObject::invokeMethod(connection, [connection]() { + connection->disconnectDevice(); + connection->deleteLater(); + }, Qt::QueuedConnection); } void AbbTerraTcpDiscovery::finishDiscovery() diff --git a/abbterra/abbterrautils.h b/abbterra/abbterrautils.h index 07580ad..f732f92 100644 --- a/abbterra/abbterrautils.h +++ b/abbterra/abbterrautils.h @@ -34,9 +34,17 @@ inline QString connectorTypeName(quint8 connectorType) } } +// The numeric fields of the serial-number and firmware registers are BCD-encoded: +// the hex representation of the byte IS its decimal value (per ABB spec §5.1, +// e.g. 0x49 = week 49, 0x22 = 22 kW), not a plain binary number (0x49 = 73). +inline quint8 bcdToDec(quint8 value) +{ + return static_cast(((value >> 4) & 0x0f) * 10 + (value & 0x0f)); +} + inline int ratedPowerFromCode(quint8 ratedPowerCode) { - switch (ratedPowerCode) { + switch (bcdToDec(ratedPowerCode)) { case 7: return 7; case 11: @@ -54,11 +62,11 @@ inline DeviceInfo deviceInfoFromValues(quint64 serialNumberRaw, quint32 firmware const quint8 connectorType = static_cast((serialNumberRaw >> 56) & 0xff); const quint8 ratedPowerCode = static_cast((serialNumberRaw >> 48) & 0xff); - const quint8 plantId = static_cast((serialNumberRaw >> 40) & 0xff); - const quint8 productionWeek = static_cast((serialNumberRaw >> 24) & 0xff); - const quint8 productionYear = static_cast((serialNumberRaw >> 16) & 0xff); - const quint8 uniqueHigh = static_cast((serialNumberRaw >> 8) & 0xff); - const quint8 uniqueLow = static_cast(serialNumberRaw & 0xff); + const quint8 plantId = bcdToDec(static_cast((serialNumberRaw >> 40) & 0xff)); + const quint8 productionWeek = bcdToDec(static_cast((serialNumberRaw >> 24) & 0xff)); + const quint8 productionYear = bcdToDec(static_cast((serialNumberRaw >> 16) & 0xff)); + const quint8 uniqueHigh = bcdToDec(static_cast((serialNumberRaw >> 8) & 0xff)); + const quint8 uniqueLow = bcdToDec(static_cast(serialNumberRaw & 0xff)); const int ratedPower = ratedPowerFromCode(ratedPowerCode); const QString connectorName = connectorTypeName(connectorType); @@ -77,9 +85,9 @@ inline DeviceInfo deviceInfoFromValues(quint64 serialNumberRaw, quint32 firmware .arg(uniqueLow, 2, 10, QLatin1Char('0')); deviceInfo.productName = QStringLiteral("ABB Terra AC %1 kW %2").arg(ratedPower).arg(connectorName); deviceInfo.firmwareVersion = QStringLiteral("%1.%2.%3") - .arg((firmwareVersionRaw >> 24) & 0xff) - .arg((firmwareVersionRaw >> 16) & 0xff) - .arg((firmwareVersionRaw >> 8) & 0xff); + .arg(bcdToDec(static_cast((firmwareVersionRaw >> 24) & 0xff))) + .arg(bcdToDec(static_cast((firmwareVersionRaw >> 16) & 0xff))) + .arg(bcdToDec(static_cast((firmwareVersionRaw >> 8) & 0xff))); if (maxCurrentRaw >= 6000 && maxCurrentRaw <= 32000) { deviceInfo.maxChargingCurrent = maxCurrentRaw / 1000.0; } diff --git a/abbterra/integrationpluginabbterra.cpp b/abbterra/integrationpluginabbterra.cpp index d7185eb..b7b8f97 100644 --- a/abbterra/integrationpluginabbterra.cpp +++ b/abbterra/integrationpluginabbterra.cpp @@ -314,6 +314,23 @@ void IntegrationPluginAbbterra::setupTcpThing(ThingSetupInfo *info) applyTimeoutSetting(thing, connection); } }); + + // Actually start the connection once the network device is online. Without + // this the connection is never established, reachableChanged() never fires, + // initialize() is never called and the setup hangs until it times out + // ("Thing setup failed"). Gate on the monitor so we only connect when the + // device's IP is known and reachable, and (re)connect if it comes back. + connect(monitor, &NetworkDeviceMonitor::reachableChanged, connection, [connection, monitor](bool reachable) { + if (reachable) { + connection->modbusTcpMaster()->setHostAddress(monitor->networkDeviceInfo().address()); + if (!connection->modbusTcpMaster()->connected()) + connection->connectDevice(); + } + }); + if (monitor->reachable()) { + connection->modbusTcpMaster()->setHostAddress(monitor->networkDeviceInfo().address()); + connection->connectDevice(); + } } void IntegrationPluginAbbterra::setupRtuThing(ThingSetupInfo *info) diff --git a/abbterra/integrationpluginabbterra.json b/abbterra/integrationpluginabbterra.json index 392c218..656c823 100644 --- a/abbterra/integrationpluginabbterra.json +++ b/abbterra/integrationpluginabbterra.json @@ -19,10 +19,8 @@ "networkdevice" ], "createMethods": [ - "discovery", "user" ], - "discoveryType": "weak", "paramTypes": [ { "id": "9b3332aa-6c26-4399-b3dc-d7fdbe3f4420", diff --git a/debian/changelog b/debian/changelog index 14b06d7..8eb0012 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,53 @@ +etm-powersync-plugins-modbus (1.15.2+etm5) trixie; urgency=medium + + * abbterra: decode the serial-number and firmware register fields as BCD, as + specified by the ABB Modbus spec §5.1 (e.g. 0x49 = week 49, 0x22 = 22 kW), + instead of as plain binary. The binary reading made deviceInfoFromValues() + reject every real charger whose BCD production-week byte exceeded 0x35 + (e.g. week 46 = 0x46 = 70 > 53), failing setup with "The device does not + match the ABB Terra AC Modbus register map", and mis-decoded the 11 kW/22 kW + rated-power codes. Manual TCP add of the charger now completes. + + -- ETM-Schurig SARL Fri, 03 Jul 2026 13:15:00 +0200 + +etm-powersync-plugins-modbus (1.15.2+etm4) trixie; urgency=medium + + * abbterra: fix TCP manual setup never establishing the connection. + setupTcpThing() wired all the connection signals but never called + connectDevice(), so reachableChanged()/initializationFinished() never fired + and the setup hung until timeout ("Thing setup failed"). Now the connection + is started when the NetworkDeviceMonitor reports the device online. + + -- ETM-Schurig SARL Fri, 03 Jul 2026 12:55:00 +0200 + +etm-powersync-plugins-modbus (1.15.2+etm3) trixie; urgency=medium + + * abbterra: disable TCP auto-discovery (createMethods = "user" only) for the + Terra AC Charger (TCP) thing class. Discovery blind-scans the LAN and blows + up nymead with a SIGSEGV as soon as any Modbus device answers :502 with an + exception response — the crash is in the generated reachability handler and + cannot be fixed from the discovery code alone (two attempts failed). For the + Waveshare RS485-gateway use case discovery can't determine the slave id + anyway; the charger must be added manually (address + port + slaveId). This + unblocks adding the charger and stops the crash loop. The underlying + generated-code crash is tracked separately. + + -- ETM-Schurig SARL Fri, 03 Jul 2026 12:40:00 +0200 + +etm-powersync-plugins-modbus (1.15.2+etm2) trixie; urgency=medium + + * abbterra: fix nymead SIGSEGV during ABB Terra AC TCP discovery. On a + reachability-check exception (e.g. any Modbus device on the LAN answering + :502 with "illegal data address"), AbbTerraTcpDiscovery::cleanupConnection() + tore the connection down synchronously from inside the connection's own + QModbusReply "finished" handler, freeing the in-flight reply that the + generated code still dereferences at abbterramodbustcpconnection.cpp:2062. + The teardown is now deferred (Qt::QueuedConnection) and made idempotent. + * abbterra: restore checkReachableRegister ("serialNumber"), required by the + libnymea-modbus 1.15 connection-class generator. + + -- ETM-Schurig SARL Fri, 03 Jul 2026 12:00:00 +0200 + etm-powersync-plugins-modbus (1.15.2+etm1) trixie; urgency=medium * Build 1.15,2 From bb842eea55bbd49836df8f9e611e783048ad3c72 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:15:59 +0200 Subject: [PATCH 04/24] sunspec: vendor plugin sources from nymea-plugins-modbus 1.15.2 Vendor the sunspec plugin into this repo so the Fronius work and the upstream storage fixes can live here. Sources only: build artefacts and generated plugininfo headers are excluded. libnymea-sunspec is deliberately NOT vendored - the plugin must stay rebasable on upstream nymea, so it links against libnymea-sunspec-dev. sunspec.pri resolves it via pkg-config, with a NYMEA_SUNSPEC_PATH override for a local sysroot, mirroring modbus.pri. See sunspec/VENDORED.md. --- .gitignore | 13 + etm-powersync-plugins-modbus.pro | 1 + sunspec.pri | 30 +- sunspec/README.md | 29 + sunspec/VENDORED.md | 45 + sunspec/integrationpluginsunspec.cpp | 1759 +++++++++++++++++ sunspec/integrationpluginsunspec.h | 125 ++ sunspec/integrationpluginsunspec.json | 1728 ++++++++++++++++ sunspec/meta.json | 14 + sunspec/solaredgebattery.cpp | 253 +++ sunspec/solaredgebattery.h | 131 ++ sunspec/sunspec.png | Bin 0 -> 8836 bytes sunspec/sunspec.pro | 14 + sunspec/sunspecdiscovery.cpp | 235 +++ sunspec/sunspecdiscovery.h | 77 + sunspec/sunspecthing.cpp | 70 + sunspec/sunspecthing.h | 63 + sunspec/test/sunspec_server.sh | 19 + ...bdec6-cf2c-4a0f-9709-42d951ca2d8b-en_US.ts | 976 +++++++++ 19 files changed, 5578 insertions(+), 4 deletions(-) create mode 100644 sunspec/README.md create mode 100644 sunspec/VENDORED.md create mode 100644 sunspec/integrationpluginsunspec.cpp create mode 100644 sunspec/integrationpluginsunspec.h create mode 100644 sunspec/integrationpluginsunspec.json create mode 100644 sunspec/meta.json create mode 100644 sunspec/solaredgebattery.cpp create mode 100644 sunspec/solaredgebattery.h create mode 100644 sunspec/sunspec.png create mode 100644 sunspec/sunspec.pro create mode 100644 sunspec/sunspecdiscovery.cpp create mode 100644 sunspec/sunspecdiscovery.h create mode 100644 sunspec/sunspecthing.cpp create mode 100644 sunspec/sunspecthing.h create mode 100755 sunspec/test/sunspec_server.sh create mode 100644 sunspec/translations/cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b-en_US.ts diff --git a/.gitignore b/.gitignore index cf32e71..6a27cf1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,16 @@ moc_* *plugininfo.h *.so build/ +builddir/ +build-*/ + +# Sysroot local pour libnymea-sunspec-dev (extraction du .deb hors installation +# systeme). Artefact de build, jamais versionne. +.sunspec-sysroot/ + +# Documents de conception du chantier Fronius : ils vivent dans hems-system et +# sont copies ici pour travailler. Leur diffusion publique n'est pas tranchee +# (cf. section 5 du contrat), donc ils ne sont pas versionnes dans ce depot. +sunspec/CONTRAT-fronius-sunspec.md +sunspec/brief-agent-plugin.md +sunspec/CLAUDE-nymea-plugin-sunspec.md diff --git a/etm-powersync-plugins-modbus.pro b/etm-powersync-plugins-modbus.pro index 5c90608..4463590 100644 --- a/etm-powersync-plugins-modbus.pro +++ b/etm-powersync-plugins-modbus.pro @@ -7,6 +7,7 @@ PLUGIN_DIRS = \ eastron \ abbb2x \ abbterra \ + sunspec \ v2c diff --git a/sunspec.pri b/sunspec.pri index d39682d..d10c263 100644 --- a/sunspec.pri +++ b/sunspec.pri @@ -1,7 +1,29 @@ QT *= network serialbus -top_srcdir=$$PWD -top_builddir=$$shadowed($$PWD) +# libnymea-sunspec n'est PAS vendoree : on lie contre le paquet +# libnymea-sunspec-dev. Deux resolutions possibles, meme convention que +# modbus.pri / NYMEA_MODBUS_PATH : +# +# 1) paquet installe systeme -> pkg-config nymea-sunspec (defaut) +# 2) sysroot local -> qmake6 NYMEA_SUNSPEC_PATH=/chemin/vers/sysroot +# ou contient usr/include/nymea-sunspec et usr/lib/. +# +# Le paquet 1.15.2 installe les en-tetes des modeles sous +# /home/jenkins/nymea-plugins-modbus/libnymea-sunspec/models/ +# (le chemin de build Jenkins a fuite dans le .deb). Le plugin fait +# #include +# qui ne resout donc pas avec le seul -I. On ajoute le prefixe +# manquant quand il existe, sans casser une installation correcte. -INCLUDEPATH += $$top_srcdir/libnymea-sunspec -LIBS += -L$$top_builddir/libnymea-sunspec/ -lnymea-sunspec1 +isEmpty(NYMEA_SUNSPEC_PATH) { + CONFIG += link_pkgconfig + PKGCONFIG += nymea-sunspec + SUNSPEC_INCLUDEDIR = $$[QT_INSTALL_PREFIX]/include/nymea-sunspec +} else { + SUNSPEC_INCLUDEDIR = $$NYMEA_SUNSPEC_PATH/usr/include/nymea-sunspec + INCLUDEPATH += $$SUNSPEC_INCLUDEDIR + LIBS += -L$$NYMEA_SUNSPEC_PATH/usr/lib/$$QMAKE_HOST.arch-linux-gnu -lnymea-sunspec1 +} + +SUNSPEC_MODELS_WORKAROUND = $$SUNSPEC_INCLUDEDIR/home/jenkins/nymea-plugins-modbus/libnymea-sunspec +exists($$SUNSPEC_MODELS_WORKAROUND/models): INCLUDEPATH += $$SUNSPEC_MODELS_WORKAROUND diff --git a/sunspec/README.md b/sunspec/README.md new file mode 100644 index 0000000..682dd1a --- /dev/null +++ b/sunspec/README.md @@ -0,0 +1,29 @@ +# SunSpec + +Connect nymea to SunSpec devices over Modbus TCP + +## Supported Things + +* SunSpec Inverter + * Model ID 101 & 111 Single phase inverter + * Model ID 102 & 112 Split phase inverter + * Model ID 103 & 113 Three phase inverter +* SunSpec Meter + * Model ID 201 & 211 Single phase meter + * Model ID 202 & 212 Split phase meter + * Model ID 203 & 213 Three phase meter +* SunSpec Storage + * Model ID 124 + + +## Tested connections + +* SolarEdge (with custom battery) + +## Requirements + +* The package 'nymea-plugin-sunspec' must be installed. +* The SunSpec device must support SunSpec over modbus TCP + +## More +https://sunspec.org diff --git a/sunspec/VENDORED.md b/sunspec/VENDORED.md new file mode 100644 index 0000000..958ee22 --- /dev/null +++ b/sunspec/VENDORED.md @@ -0,0 +1,45 @@ +# Vendoring — sunspec + +Copié depuis : ETM/nymea-plugins-modbus (fork de nymea/nymea-plugins-modbus) +Commit source (HEAD au moment de la copie) : 2b18e84 +Dernier commit touchant les sources copiées : bf53a74 + ("Update license text and add SPDX identifier", 2025-11-18) +Base upstream : nymea-plugins-modbus 1.15.2 +Date copie : 2026-07-31 +Nom paquet cible : powersync-plugin-sunspec + +Raison : le chantier Fronius (voir `specs/fronius-sunspec.md`) ajoute un vendor +`fronius` et corrige des anomalies du vendor `sunspec` générique. Ces +changements vivent dans ce dépôt ETM, pas dans le mirror nymea. + +## Ce qui a été copié + +Sources uniquement. Exclus de la copie : `*.o`, `moc_*`, `*.so`, `Makefile`, +`plugininfo.h`, `extern-plugininfo.h`, `moc_predefs.h`, `*.qm` — tous des +artefacts de build régénérés par `qmake6` / `nymea-plugininfocompiler`. + +## Dépendance de build + +`libnymea-sunspec-dev` (paquet `repos.nymea.io`, 1.15.2). `sunspec.pri` le +résout par `pkg-config nymea-sunspec`. + +## Règle absolue + +**`libnymea-sunspec` n'est PAS vendorée et ne doit pas l'être.** Le plugin doit +rester rebasable sur l'upstream nymea : toute modification vit dans `sunspec/`. +Si un changement de la bibliothèque paraît inévitable, s'arrêter et remonter la +question plutôt que de la modifier. + +## Hygiène de commits + +Deux natures de changements, à ne jamais mélanger dans un même commit : + +- **correctifs upstream** (bugs présents dans nymea 1.15.2 — AC-1 à AC-4 de la + spec) : message en anglais, aucune référence à ETM / Fronius / PowerSync, pour + rester proposables en amont ; +- **spécifique ETM** (vendor `fronius`, réglages produit) : commits séparés. + +## SORTIE + +Si nymea intègre en amont les correctifs AC-1..AC-4 et un vendor Fronius +équivalent : rebaser sur la version amont et réduire ce dossier au delta ETM. diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp new file mode 100644 index 0000000..2ef9d3d --- /dev/null +++ b/sunspec/integrationpluginsunspec.cpp @@ -0,0 +1,1759 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "integrationpluginsunspec.h" +#include "plugininfo.h" + +#include + +#include +#include +#include + +// Inverters +#include +#include +#include +#include +#include +#include + +// Meters +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "sunspecdiscovery.h" +#include "solaredgebattery.h" + +#include + +IntegrationPluginSunSpec::IntegrationPluginSunSpec() +{ + +} + +void IntegrationPluginSunSpec::init() +{ + // SunSpec connection params + m_connectionDiscoverySlaveIdParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionDiscoverySlaveIdParamTypeId); + m_connectionDiscoverySlaveIdParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionDiscoverySlaveIdParamTypeId); + + m_connectionMacAddressParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingMacAddressParamTypeId); + m_connectionMacAddressParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingMacAddressParamTypeId); + + m_connectionHostNameParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingHostNameParamTypeId); + m_connectionHostNameParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingHostNameParamTypeId); + + m_connectionAddressParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingAddressParamTypeId); + m_connectionAddressParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingAddressParamTypeId); + + m_connectionPortParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingPortParamTypeId); + m_connectionPortParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingPortParamTypeId); + + m_connectionSlaveIdParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingSlaveIdParamTypeId); + m_connectionSlaveIdParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingSlaveIdParamTypeId); + + // Params for sunspec things + m_modelIdParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingModelIdParamTypeId); + m_modelIdParamTypeIds.insert(sunspecSplitPhaseInverterThingClassId, sunspecSplitPhaseInverterThingModelIdParamTypeId); + m_modelIdParamTypeIds.insert(sunspecThreePhaseInverterThingClassId, sunspecThreePhaseInverterThingModelIdParamTypeId); + m_modelIdParamTypeIds.insert(sunspecStorageThingClassId, sunspecStorageThingModelIdParamTypeId); + m_modelIdParamTypeIds.insert(sunspecSinglePhaseMeterThingClassId, sunspecSinglePhaseMeterThingModelIdParamTypeId); + m_modelIdParamTypeIds.insert(sunspecSplitPhaseMeterThingClassId, sunspecSplitPhaseMeterThingModelIdParamTypeId); + m_modelIdParamTypeIds.insert(sunspecThreePhaseMeterThingClassId, sunspecThreePhaseMeterThingModelIdParamTypeId); + + m_modbusAddressParamTypeIds.insert(solarEdgeBatteryThingClassId, solarEdgeBatteryThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecSplitPhaseInverterThingClassId, sunspecSplitPhaseInverterThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecThreePhaseInverterThingClassId, sunspecThreePhaseInverterThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecStorageThingClassId, sunspecStorageThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecSinglePhaseMeterThingClassId, sunspecSinglePhaseMeterThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecSplitPhaseMeterThingClassId, sunspecSplitPhaseMeterThingModbusAddressParamTypeId); + m_modbusAddressParamTypeIds.insert(sunspecThreePhaseMeterThingClassId, sunspecThreePhaseMeterThingModbusAddressParamTypeId); + + m_manufacturerParamTypeIds.insert(solarEdgeBatteryThingClassId, solarEdgeBatteryThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecSplitPhaseInverterThingClassId, sunspecSplitPhaseInverterThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecThreePhaseInverterThingClassId, sunspecThreePhaseInverterThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecStorageThingClassId, sunspecStorageThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecSinglePhaseMeterThingClassId, sunspecSinglePhaseMeterThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecSplitPhaseMeterThingClassId, sunspecSplitPhaseMeterThingManufacturerParamTypeId); + m_manufacturerParamTypeIds.insert(sunspecThreePhaseMeterThingClassId, sunspecThreePhaseMeterThingManufacturerParamTypeId); + + m_deviceModelParamTypeIds.insert(solarEdgeBatteryThingClassId, solarEdgeBatteryThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecSplitPhaseInverterThingClassId, sunspecSplitPhaseInverterThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecThreePhaseInverterThingClassId, sunspecThreePhaseInverterThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecStorageThingClassId, sunspecStorageThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecSinglePhaseMeterThingClassId, sunspecSinglePhaseMeterThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecSplitPhaseMeterThingClassId, sunspecSplitPhaseMeterThingDeviceModelParamTypeId); + m_deviceModelParamTypeIds.insert(sunspecThreePhaseMeterThingClassId, sunspecThreePhaseMeterThingDeviceModelParamTypeId); + + m_serialNumberParamTypeIds.insert(solarEdgeBatteryThingClassId, solarEdgeBatteryThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecSplitPhaseInverterThingClassId, sunspecSplitPhaseInverterThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecThreePhaseInverterThingClassId, sunspecThreePhaseInverterThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecStorageThingClassId, sunspecStorageThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecSinglePhaseMeterThingClassId, sunspecSinglePhaseMeterThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecSplitPhaseMeterThingClassId, sunspecSplitPhaseMeterThingSerialNumberParamTypeId); + m_serialNumberParamTypeIds.insert(sunspecThreePhaseMeterThingClassId, sunspecThreePhaseMeterThingSerialNumberParamTypeId); +} + +void IntegrationPluginSunSpec::discoverThings(ThingDiscoveryInfo *info) +{ + if (!hardwareManager()->networkDeviceDiscovery()->available()) { + qCWarning(dcSunSpec()) << "Failed to discover network devices. The network device discovery is not available."; + info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("The discovery is not available.")); + return; + } + + + SunSpecDataPoint::ByteOrder byteOrder = SunSpecDataPoint::ByteOrderLittleEndian; + if (info->thingClassId() == solarEdgeConnectionThingClassId) + byteOrder = SunSpecDataPoint::ByteOrderBigEndian; + + quint16 discoverySlaveId = info->params().paramValue(m_connectionDiscoverySlaveIdParamTypeIds.value(info->thingClassId())).toUInt(); + + SunSpecDiscovery *discovery = new SunSpecDiscovery(hardwareManager()->networkDeviceDiscovery(), discoverySlaveId, byteOrder, info); + // Note: we could add here more + connect(discovery, &SunSpecDiscovery::discoveryFinished, info, [this, info, discovery](){ + foreach (const SunSpecDiscovery::Result &result, discovery->results()) { + + // Extract the manufacturer: we pick the first manufacturer name of the first common model having a manufacturer name for now + QString manufacturer; + if (!result.modelManufacturers.isEmpty()) + manufacturer = result.modelManufacturers.first(); + + qCDebug(dcSunSpec()) << "Found manufacturers on" << result.networkDeviceInfo << result.port; + qCDebug(dcSunSpec()) << "Manufacturers:" << result.modelManufacturers; + qCDebug(dcSunSpec()) << "Picking manufacturer for evaluation:" << manufacturer; + + // Filter for solar edge if we got one here + if (info->thingClassId() == solarEdgeConnectionThingClassId) { + if (!hasManufacturer(result.modelManufacturers, "solaredge") && !hasManufacturer(result.modelManufacturers, "solar edge")) { + // Solar edge...we must have the manufacturer in one common model + continue; + } else { + manufacturer = "SolarEdge"; + } + } else if (info->thingClassId() == sunspecConnectionThingClassId) { + // There are some issues regarding the sunspec implementation of kostal. + // Full support of meter, inverter and storage will be provided in the kostal plugin which makes + // use of the native modbus communication from kostal. + if (hasManufacturer(result.modelManufacturers, "kostal")) { + qCWarning(dcSunSpec()) << "Skipping Kostal manufacturer on SunSpec. Please use the native kostal integration plugin in order to add it to the system."; + continue; + } + } + + QString title; + if (!manufacturer.isEmpty()) { + title = manufacturer + " "; + } + title.append("SunSpec connection"); + + QString description; + MacAddressInfo macInfo; + switch (result.networkDeviceInfo.monitorMode()) { + case NetworkDeviceInfo::MonitorModeMac: + macInfo = result.networkDeviceInfo.macAddressInfos().constFirst(); + description = result.networkDeviceInfo.address().toString(); + if (!macInfo.vendorName().isEmpty()) + description += " - " + result.networkDeviceInfo.macAddressInfos().constFirst().vendorName(); + + break; + case NetworkDeviceInfo::MonitorModeHostName: + description = result.networkDeviceInfo.hostName(); + break; + case NetworkDeviceInfo::MonitorModeIp: + description = "Interface: " + result.networkDeviceInfo.networkInterface().name(); + break; + } + + ThingDescriptor descriptor(info->thingClassId(), title, description); + + ParamList params; + params << Param(m_connectionMacAddressParamTypeIds.value(info->thingClassId()), result.networkDeviceInfo.thingParamValueMacAddress()); + params << Param(m_connectionHostNameParamTypeIds.value(info->thingClassId()), result.networkDeviceInfo.thingParamValueHostName()); + params << Param(m_connectionAddressParamTypeIds.value(info->thingClassId()), result.networkDeviceInfo.thingParamValueAddress()); + params << Param(m_connectionPortParamTypeIds.value(info->thingClassId()), result.port); + params << Param(m_connectionSlaveIdParamTypeIds.value(info->thingClassId()), result.slaveId); + descriptor.setParams(params); + + // Check if we already have set up this device + Thing *existingThing = myThings().findByParams(params); + if (existingThing) { + qCDebug(dcSunSpec()) << "This thing already exists in the system:" << result.networkDeviceInfo; + descriptor.setThingId(existingThing->id()); + } + + info->addThingDescriptor(descriptor); + } + + // Discovery done + info->finish(Thing::ThingErrorNoError); + }); + + discovery->startDiscovery(); +} + +void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + qCDebug(dcSunSpec()) << "Setup thing" << thing; + qCDebug(dcSunSpec()) << thing->params(); + + if (thing->thingClassId() == sunspecConnectionThingClassId || + thing->thingClassId() == solarEdgeConnectionThingClassId) { + + // Handle reconfigure + if (m_sunSpecConnections.contains(thing->id())) { + qCDebug(dcSunSpec()) << "Reconfiguring existing thing" << thing->name(); + m_sunSpecConnections.take(thing->id())->deleteLater(); + + if (m_monitors.contains(thing)) { + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); + } + } + + // Create the monitor + NetworkDeviceMonitor *monitor = hardwareManager()->networkDeviceDiscovery()->registerMonitor(thing); + if (!monitor) { + qCWarning(dcSunSpec()) << "Unable to register monitor with the given params" << thing->params(); + info->finish(Thing::ThingErrorInvalidParameter, QT_TR_NOOP("Unable to set up the connection with this configuration, please reconfigure the connection.")); + return; + } + + m_monitors.insert(thing, monitor); + + QHostAddress address = monitor->networkDeviceInfo().address(); + if (address.isNull() && info->isInitialSetup()) { + qCWarning(dcSunSpec()) << "Cannot set up thing. The host address is not known and this is an initial setup"; + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); + info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("The host address is not known yet. Trying later again.")); + return; + } + + // Clean up in case the setup gets aborted + connect(info, &ThingSetupInfo::aborted, monitor, [=](){ + if (m_monitors.contains(thing)) { + qCDebug(dcSunSpec()) << "Unregister monitor because setup has been aborted."; + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); + } + }); + + SunSpecConnection *connection = createConnection(info->thing()); + connect(info, &ThingSetupInfo::aborted, connection, &SunSpecConnection::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()) { + + // Only during setup + connect(connection, &SunSpecConnection::connectedChanged, info, [this, connection, info] (bool connected) { + if (connected) { + connect(connection, &SunSpecConnection::discoveryFinished, info, [this, connection, info] (bool success) { + if (success) { + qCDebug(dcSunSpec()) << "Discovery finished successfully during setup of" << connection << ". Found SunSpec data on base register" << connection->baseRegister(); + m_sunSpecConnections.insert(info->thing()->id(), connection); + info->finish(Thing::ThingErrorNoError); + processDiscoveryResult(info->thing(), connection); + } else { + qCWarning(dcSunSpec()) << "Discovery finished with errors during setup of" << connection; + info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("The SunSpec discovery finished with errors. Please make sure this is a SunSpec device.")); + connection->deleteLater(); + } + }); + + // Perform initial discovery, finish if a valid base register has been found + connection->startDiscovery(); + } else { + info->finish(Thing::ThingErrorHardwareNotAvailable); + connection->deleteLater(); + } + }); + + connect(info, &ThingSetupInfo::aborted, connection, &SunSpecConnection::deleteLater); + + if (!connection->connectDevice()) { + qCWarning(dcSunSpec()) << "Error connecting to SunSpec device" << thing; + info->finish(Thing::ThingErrorHardwareNotAvailable); + connection->deleteLater(); + return; + } + + } else { + m_sunSpecConnections.insert(thing->id(), connection); + + // Finish the setup in any case, not the initial setup and the monitor takes care about connecting + info->finish(Thing::ThingErrorNoError); + + if (monitor->reachable()) { + // Thing already reachable...let's continue with the setup + connection->connectDevice(); + } else { + // Wait for the monitor to be ready + qCDebug(dcSunSpec()) << "Waiting for the network monitor to get reachable before continue to set up the connection" << thing->name() << address.toString() << "..."; + } + } + + } else if (thing->thingClassId() == sunspecThreePhaseInverterThingClassId || + thing->thingClassId() == sunspecSplitPhaseInverterThingClassId || + thing->thingClassId() == sunspecSinglePhaseInverterThingClassId ) { + + 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; + } + } + } + info->finish(Thing::ThingErrorNoError); + } else if (thing->thingClassId() == sunspecThreePhaseMeterThingClassId + || thing->thingClassId() == sunspecSplitPhaseMeterThingClassId + || thing->thingClassId() == sunspecSinglePhaseMeterThingClassId ) { + + 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; + } + } + } else { + // Note: will be initialized once the connection is available and discovered. + qCDebug(dcSunSpec()) << "Model not available yet for" << thing; + } + + info->finish(Thing::ThingErrorNoError); + } else if (thing->thingClassId() == sunspecStorageThingClassId) { + 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; + } + } + } else { + // Note: will be initialized once the connection is available and discovered. + qCDebug(dcSunSpec()) << "Model not available yet for" << thing; + } + + info->finish(Thing::ThingErrorNoError); + } else if (thing->thingClassId() == solarEdgeBatteryThingClassId) { + + Thing *parentThing = myThings().findById(thing->parentId()); + if (parentThing->setupStatus() == Thing::ThingSetupStatusComplete) { + setupSolarEdgeBattery(info); + } else { + connect(parentThing, &Thing::setupStatusChanged, info, [this, info] { + setupSolarEdgeBattery(info); + }); + } + } else { + Q_ASSERT_X(false, "setupThing", QString("Unhandled thingClassId: %1").arg(info->thing()->thingClassId().toString()).toUtf8()); + } +} + +void IntegrationPluginSunSpec::postSetupThing(Thing *thing) +{ + qCDebug(dcSunSpec()) << "Post setup thing" << thing->name(); + + if (thing->thingClassId() == solarEdgeConnectionThingClassId) { + SunSpecConnection *connection = m_sunSpecConnections.value(thing->id()); + if (connection) { + searchSolarEdgeBatteries(connection); + } + } + + // Create the refresh timer if not already set up + if (!m_refreshTimer) { + qCDebug(dcSunSpec()) << "Starting refresh timer"; + int refreshTime = configValue(sunSpecPluginUpdateIntervalParamTypeId).toInt(); + m_refreshTimer = hardwareManager()->pluginTimerManager()->registerTimer(refreshTime); + connect(m_refreshTimer, &PluginTimer::timeout, this, &IntegrationPluginSunSpec::onRefreshTimer); + } +} + +void IntegrationPluginSunSpec::thingRemoved(Thing *thing) +{ + qCDebug(dcSunSpec()) << "Thing removed" << thing->name(); + + if (m_sunSpecConnections.contains(thing->id())) { + m_sunSpecConnections.take(thing->id())->deleteLater(); + } else if (m_sunSpecThings.contains(thing)) { + m_sunSpecThings.take(thing)->deleteLater(); + } else if (m_sunSpecInverters.contains(thing)) { + m_sunSpecInverters.remove(thing); + } else if (m_sunSpecMeters.contains(thing)) { + m_sunSpecMeters.remove(thing); + } else if (m_sunSpecStorages.contains(thing)) { + m_sunSpecStorages.remove(thing); + } else { + Q_ASSERT_X(false, "thingRemoved", QString("Unhandled thingClassId: %1").arg(thing->thingClassId().toString()).toUtf8()); + } + + if (m_monitors.contains(thing)) + hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing)); + + if (myThings().isEmpty()) { + qCDebug(dcSunSpec()) << "Stopping refresh timer"; + hardwareManager()->pluginTimerManager()->unregisterTimer(m_refreshTimer); + m_refreshTimer = nullptr; + } +} + +void IntegrationPluginSunSpec::executeAction(ThingActionInfo *info) +{ + Thing *thing = info->thing(); + Action action = info->action(); + + if (thing->thingClassId() == sunspecStorageThingClassId) { + SunSpecStorageModel *storage = qobject_cast(m_sunSpecStorages.value(thing)); + if (!storage) { + qWarning(dcSunSpec()) << "Could not find sunspec model instance for thing" << thing; + info->finish(Thing::ThingErrorHardwareNotAvailable); + return; + } + + if (!thing->stateValue(sunspecStorageConnectedStateTypeId).toBool()) { + qWarning(dcSunSpec()) << "Could not execute action for" << thing << "because the SunSpec connection is not connected."; + info->finish(Thing::ThingErrorHardwareNotAvailable, QT_TR_NOOP("The SunSpec connection is not connected.")); + return; + } + + if (action.actionTypeId() == sunspecStorageGridChargingActionTypeId) { + bool gridCharging = action.param(sunspecStorageGridChargingActionGridChargingParamTypeId).value().toBool(); + QModbusReply *reply = storage->setChaGriSet(gridCharging ? SunSpecStorageModel::ChagrisetGrid : SunSpecStorageModel::ChagrisetPv); + if (!reply) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, info, [info, reply]{ + if (reply->error() != QModbusDevice::NoError) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + info->finish(Thing::ThingErrorNoError); + }); + } else if (action.actionTypeId() == sunspecStorageEnableChargingActionTypeId) { + SunSpecStorageModel::Storctl_modFlags controlModeFlags = storage->storCtlMod(); + bool enabled = action.param(sunspecStorageEnableChargingActionEnableChargingParamTypeId).value().toBool(); + controlModeFlags.setFlag(SunSpecStorageModel::Storctl_modCharge, enabled); + QModbusReply *reply = storage->setStorCtlMod(controlModeFlags); + if (!reply) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, info, [info, reply, enabled]{ + if (reply->error() != QModbusDevice::NoError) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + info->thing()->setStateValue(sunspecStorageEnableChargingStateTypeId, enabled); + info->finish(Thing::ThingErrorNoError); + }); + } else if (action.actionTypeId() == sunspecStorageEnableDischargingActionTypeId) { + SunSpecStorageModel::Storctl_modFlags controlModeFlags = storage->storCtlMod(); + bool enabled = action.param(sunspecStorageEnableDischargingActionEnableDischargingParamTypeId).value().toBool(); + controlModeFlags.setFlag(SunSpecStorageModel::Storctl_modDiScharge, enabled); + QModbusReply *reply = storage->setStorCtlMod(controlModeFlags); + if (!reply) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, info, [info, reply, enabled]{ + if (reply->error() != QModbusDevice::NoError) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + info->thing()->setStateValue(sunspecStorageEnableDischargingStateTypeId, enabled); + info->finish(Thing::ThingErrorNoError); + }); + } else if (action.actionTypeId() == sunspecStorageChargingRateActionTypeId) { + QModbusReply *reply = storage->setInWRte(action.param(sunspecStorageChargingRateActionChargingRateParamTypeId).value().toInt()); + if (!reply) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, info, [info, reply]{ + if (reply->error() != QModbusDevice::NoError) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + info->finish(Thing::ThingErrorNoError); + }); + } else if (action.actionTypeId() == sunspecStorageDischargingRateActionTypeId) { + QModbusReply *reply = storage->setOutWRte(action.param(sunspecStorageDischargingRateActionDischargingRateParamTypeId).value().toInt()); + if (!reply) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, info, [info, reply]{ + if (reply->error() != QModbusDevice::NoError) { + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + info->finish(Thing::ThingErrorNoError); + }); + } else { + Q_ASSERT_X(false, "executeAction", QString("Unhandled action: %1").arg(action.actionTypeId().toString()).toUtf8()); + } + + } else { + Q_ASSERT_X(false, "executeAction", QString("Unhandled thingClassId: %1").arg(info->thing()->thingClassId().toString()).toUtf8()); + } +} + +Thing *IntegrationPluginSunSpec::getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId) +{ + foreach (Thing *thing, myThings()) { + if (!m_modelIdParamTypeIds.contains(thing->thingClassId())) + continue; + + uint thingModelId = thing->paramValue(m_modelIdParamTypeIds.value(thing->thingClassId())).toUInt(); + uint thingModbusAddress = thing->paramValue(m_modbusAddressParamTypeIds.value(thing->thingClassId())).toUInt(); + if (thingModelId == modelId && thingModbusAddress == modbusAddress && thing->parentId() == parentId) { + return thing; + } + } + + return nullptr; +} + +bool IntegrationPluginSunSpec::sunspecThingAlreadyAdded(uint modelId, uint modbusAddress, const ThingId &parentId) +{ + return getThingForSunSpecModel(modelId, modbusAddress, parentId)!= nullptr; +} + +void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConnection *connection) +{ + qCInfo(dcSunSpec()) << "Processing discovery result from" << thing->name() << connection; + + foreach (SunSpecModel *model, connection->models()) { + Thing *modelThing = getThingForSunSpecModel(model->modelId(), model->modbusStartRegister(), thing->id()); + if (modelThing) { + + // We already set up a thing for this model. Make sure we are actually monitoring and updating it + + qCDebug(dcSunSpec()) << "Found" << modelThing << "for" << model; + + if (modelThing->thingClassId() == sunspecSinglePhaseInverterThingClassId + || modelThing->thingClassId() == sunspecSplitPhaseInverterThingClassId + || modelThing->thingClassId() == sunspecThreePhaseInverterThingClassId) { + + if (!m_sunSpecInverters.contains(modelThing)) { + m_sunSpecInverters.insert(modelThing, model); + connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onInverterBlockUpdated); + qCDebug(dcSunSpec()) << "Model initialized successfully for" << modelThing; + } + } else if (modelThing->thingClassId() == sunspecSinglePhaseMeterThingClassId + || modelThing->thingClassId() == sunspecSplitPhaseMeterThingClassId + || modelThing->thingClassId() == sunspecThreePhaseMeterThingClassId) { + + if (!m_sunSpecMeters.contains(modelThing)) { + m_sunSpecMeters.insert(modelThing, model); + connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onMeterBlockUpdated); + qCDebug(dcSunSpec()) << "Model initialized successfully for" << modelThing; + } + } else if (modelThing->thingClassId() == sunspecStorageThingClassId) { + + if (!m_sunSpecStorages.contains(modelThing)) { + m_sunSpecStorages.insert(modelThing, model); + connect(model, &SunSpecModel::blockUpdated, this, &IntegrationPluginSunSpec::onStorageBlockUpdated); + qCDebug(dcSunSpec()) << "Model initialized successfully for" << modelThing; + } + } + } else { + + // No thing for this model, let's see if we can create one + + switch (model->modelId()) { + case SunSpecModelFactory::ModelIdCommon: + // Skip the common model, we already handled this one for each thing model + break; + case SunSpecModelFactory::ModelIdInverterSinglePhase: + case SunSpecModelFactory::ModelIdInverterSinglePhaseFloat: + autocreateSunSpecModelThing(sunspecSinglePhaseInverterThingClassId, QT_TR_NOOP("Single Phase Inverter"), thing->id(), model); + break; + case SunSpecModelFactory::ModelIdInverterSplitPhase: + case SunSpecModelFactory::ModelIdInverterSplitPhaseFloat: + autocreateSunSpecModelThing(sunspecSplitPhaseInverterThingClassId, QT_TR_NOOP("Split Phase Inverter"), thing->id(), model); + break; + case SunSpecModelFactory::ModelIdInverterThreePhase: + case SunSpecModelFactory::ModelIdInverterThreePhaseFloat: + autocreateSunSpecModelThing(sunspecThreePhaseInverterThingClassId, QT_TR_NOOP("Three Phase Inverter"), thing->id(), model); + break; + case SunSpecModelFactory::ModelIdMeterSinglePhase: + case SunSpecModelFactory::ModelIdMeterSinglePhaseFloat: + autocreateSunSpecModelThing(sunspecSinglePhaseMeterThingClassId, QT_TR_NOOP("Single Phase Meter"), thing->id(), model); + break; + case SunSpecModelFactory::ModelIdMeterSplitSinglePhaseAbn: + case SunSpecModelFactory::ModelIdMeterSplitSinglePhaseFloat: + autocreateSunSpecModelThing(sunspecSplitPhaseMeterThingClassId, QT_TR_NOOP("Split Phase Meter"), thing->id(), model); + break; + case SunSpecModelFactory::ModelIdMeterThreePhase: + case SunSpecModelFactory::ModelIdDeltaConnectThreePhaseAbcMeter: + case SunSpecModelFactory::ModelIdMeterThreePhaseWyeConnect: + case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect: + autocreateSunSpecModelThing(sunspecThreePhaseMeterThingClassId, QT_TR_NOOP("Three Phase Meter"), thing->id(), model); + break; + case SunSpecModelFactory::ModelIdStorage: + autocreateSunSpecModelThing(sunspecStorageThingClassId, QT_TR_NOOP("Storage"), thing->id(), model); + break; + default: + qCWarning(dcSunSpec()) << "Plugin has no implementation for detected" << model; + break; + } + } + } + + // Check if we have a model for all child devices, otherwise we let the device dissappear + // As of now there might be following situation where this code is required: + // - A setup has changed and something has been removed or replaced + // - Some SunSpec device seem to communicate different model id depending on the startup phase + // i.e. they communicate a SinglePhase Meter on register x, few minutes later it is a 3 phase meter on x + // This code should handle such weird setups... + + if (connection->models().isEmpty()) + return; + + foreach (Thing *child, myThings().filterByParentId(thing->id())) { + if (!m_modelIdParamTypeIds.contains(child->thingClassId()) || !m_modbusAddressParamTypeIds.contains(child->thingClassId())) + continue; + + uint childModelId = child->paramValue(m_modelIdParamTypeIds.value(child->thingClassId())).toUInt(); + uint childModbusAddress = child->paramValue(m_modbusAddressParamTypeIds.value(child->thingClassId())).toUInt(); + + bool modelFoundForChild = false; + foreach (SunSpecModel *model, connection->models()) { + if (childModelId == model->modelId() && childModbusAddress == model->modbusStartRegister()) { + modelFoundForChild = true; + break; + } + } + + if (!modelFoundForChild) { + qCInfo(dcSunSpec()) << "The model for" << child << "does not seem to be available any more on" << connection << "Removing the device since it does not seem to exist ony more on this connection."; + emit autoThingDisappeared(child->id()); + } + } +} + + +SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) +{ + QHostAddress address = m_monitors.value(thing)->networkDeviceInfo().address(); + int port = thing->paramValue(m_connectionPortParamTypeIds.value(thing->thingClassId())).toInt(); + int slaveId = thing->paramValue(m_connectionSlaveIdParamTypeIds.value(thing->thingClassId())).toInt(); + + if (m_sunSpecConnections.contains(thing->id())) { + qCDebug(dcSunSpec()) << "Reconfigure SunSpec connection" << thing; + m_sunSpecConnections.take(thing->id())->deleteLater(); + } + + SunSpecConnection *connection = nullptr; + if (thing->thingClassId() == solarEdgeConnectionThingClassId) { + // Note: for some reason, solar edge is using big endian register order instead + // of little endian as specified in sunspec and even solar edge documentation + connection = new SunSpecConnection(address, port, slaveId, SunSpecDataPoint::ByteOrderBigEndian, this); + } else { + SunSpecDataPoint::ByteOrder endianness = SunSpecDataPoint::ByteOrderLittleEndian; + QString endiannessParam = thing->paramValue("endianness").toString(); + if (endiannessParam == "Big Endian") + endianness = SunSpecDataPoint::ByteOrderBigEndian; + + connection = new SunSpecConnection(address, port, slaveId, endianness, this); + } + connection->setTimeout(configValue(sunSpecPluginTimeoutParamTypeId).toUInt()); + connection->setNumberOfRetries(configValue(sunSpecPluginNumberOfRetriesParamTypeId).toUInt()); + + // Reconnect on monitor reachable changed + NetworkDeviceMonitor *monitor = m_monitors.value(thing); + connect(monitor, &NetworkDeviceMonitor::reachableChanged, thing, [=](bool reachable){ + qCDebug(dcSunSpec()) << "Network device monitor reachable changed for" << thing->name() << reachable; + if (!thing->setupComplete()) + return; + + if (reachable && !thing->stateValue("connected").toBool()) { + qCDebug(dcSunSpec()) << "The monitor is reachable. Set the host address to" << monitor->networkDeviceInfo().address() << "and start connecting..."; + connection->setHostAddress(monitor->networkDeviceInfo().address()); + connection->connectDevice(); + } + }); + + m_sunSpecConnections.insert(thing->id(), connection); + + // Update all child things connected states for this connection + connect(connection, &SunSpecConnection::connectedChanged, thing, [this, connection, thing] (bool connected) { + if (connected) { + qCDebug(dcSunSpec()) << connection << "connected"; + } else { + qCWarning(dcSunSpec()) << connection << "disconnected"; + } + + thing->setStateValue("connected", connected); + + if (connected) { + if (thing->setupComplete()) { + // We rediscovery all models on every connect to keep the child things in sync + connection->startDiscovery(); + } + } + // Update connected state of child things + foreach (Thing *child, myThings().filterByParentId(thing->id())) { + if (connected) { + child->setStateValue("connected", true); + } else { + markThingStatesDisconnected(child); + } + } + }); + + connect(connection, &SunSpecConnection::discoveryFinished, thing, [this, connection, thing] (bool success) { + if (success) { + qCDebug(dcSunSpec()) << "Discovery finished successfully of" << connection; + processDiscoveryResult(thing, connection); + } else { + qCWarning(dcSunSpec()) << "Discovery finished with errors on" << connection; + } + }); + + return connection; +} + +void IntegrationPluginSunSpec::setupSolarEdgeBattery(ThingSetupInfo *info) +{ + Thing *thing = info->thing(); + int modbusStartRegister = thing->paramValue(solarEdgeBatteryThingModbusAddressParamTypeId).toUInt(); + SunSpecConnection *connection = m_sunSpecConnections.value(thing->parentId()); + if (!connection) { + qCWarning(dcSunSpec()) << "Could not find SunSpec parent connection for sunspec battery" << thing; + info->finish(Thing::ThingErrorHardwareNotAvailable); + return; + } + + qCDebug(dcSunSpec()) << "Setting up SolarEdge battery..."; + SolarEdgeBattery *battery = new SolarEdgeBattery(thing, connection, modbusStartRegister, connection); + + m_sunSpecThings.insert(thing, battery); + connect(battery, &SolarEdgeBattery::blockDataUpdated, this, &IntegrationPluginSunSpec::onSolarEdgeBatteryBlockUpdated); + info->finish(Thing::ThingErrorNoError); + + // Start initializing battery data + if (connection->connected()) + battery->init(); +} + +void IntegrationPluginSunSpec::searchSolarEdgeBatteries(SunSpecConnection *connection) +{ + if (!connection->connected()) { + qCDebug(dcSunSpec()) << "Could not search for SolarEdge batteries, the connection does not seem to be available at the moment."; + return; + } + + qCDebug(dcSunSpec()) << "Searching for connected SolarEdge batteries..."; + ThingId parentThingId = m_sunSpecConnections.key(connection); + if (parentThingId.isNull()) { + qCWarning(dcSunSpec()) << "Could not search for SolarEdge batteries because of find parent ThingId connection for" << connection->hostAddress().toString(); + return; + } + + // Batteries are not mapped to the sunspec layer, so we have to treat them as normal modbus registers. + // Read the battery id to verify if the battery is connected. + // Battery 1: start register 0xE100, device id register 0xE140 + // Battery 2: start register 0xE200, device id register 0xE240 + searchSolarEdgeBattery(connection, parentThingId, 0xE100); + searchSolarEdgeBattery(connection, parentThingId, 0xE200); +} + +void IntegrationPluginSunSpec::searchSolarEdgeBattery(SunSpecConnection *connection, const ThingId &parentThingId, quint16 startRegister) +{ + // Read the battery data, if init failed there is no battery connected, + // otherwise there is a battery and we should check if we need to setup the battery thing + + // Create a temporary battery object without thing for init + qCDebug(dcSunSpec()) << "Checking presence of SolarEdge battery on modbus register" << startRegister; + SolarEdgeBattery *battery = new SolarEdgeBattery(nullptr, connection, startRegister, connection); + connect(battery, &SolarEdgeBattery::initFinished, connection, [=](bool success) { + // Delete this object since we used it only for set up + battery->deleteLater(); + + // If init failed, no battery connected + if (!success) { + qCDebug(dcSunSpec()) << "No SolarEdge battery connected on register" << startRegister << "- not creating thing."; + return; + } + + qCDebug(dcSunSpec()) << "Battery initialized successfully." << battery->batteryData().manufacturerName << battery->batteryData().model; + // Check if we already created this battery + if (!myThings().filterByParam(solarEdgeBatteryThingSerialNumberParamTypeId, battery->batteryData().serialNumber).isEmpty()) { + qCDebug(dcSunSpec()) << "Battery already set up" << battery->batteryData().serialNumber; + } else { + // Create new battery device in the system + ThingDescriptor descriptor(solarEdgeBatteryThingClassId, battery->batteryData().manufacturerName + " - " + battery->batteryData().model, QString(), parentThingId); + ParamList params; + params.append(Param(solarEdgeBatteryThingModbusAddressParamTypeId, startRegister)); + params.append(Param(solarEdgeBatteryThingManufacturerParamTypeId, battery->batteryData().manufacturerName)); + params.append(Param(solarEdgeBatteryThingDeviceModelParamTypeId, battery->batteryData().model)); + params.append(Param(solarEdgeBatteryThingSerialNumberParamTypeId, battery->batteryData().serialNumber)); + descriptor.setParams(params); + emit autoThingsAppeared({descriptor}); + } + }); + + // Try to initialize battery data + battery->init(); +} + +double IntegrationPluginSunSpec::calculateSolarEdgePvProduction(Thing *thing, double acPower, double dcPower) +{ + // Note: for solar edge, we need to calculate pv power from battery and AC/DC current depending + // on loading or discharging of the battery + + double pvPower = acPower; + Thing *parentThing = myThings().findById(thing->parentId()); + if (parentThing && parentThing->thingClassId() == solarEdgeConnectionThingClassId) { + SolarEdgeBattery *battery = nullptr; + // This is a solar edge, let's see if we have a battery for this connection + foreach (Thing *sunspecThing, m_sunSpecThings.keys()) { + if (sunspecThing->thingClassId() == solarEdgeBatteryThingClassId && thing->parentId() == parentThing->id()) { + battery = qobject_cast(m_sunSpecThings.value(sunspecThing)); + break; + } + } + + // Check if we have a meter... + Thing *meterThing = nullptr; + foreach (Thing *sunspecThing, m_sunSpecMeters.keys()) { + if (thing->parentId() == parentThing->id()) { + meterThing = sunspecThing; + break; + } + } + + // This is a solar edge, let's see if we have a battery for this connection + if (battery) { + double meterCurrentPower = meterThing ? meterThing->stateValue("currentPower").toDouble() : 0; + qCDebug(dcSunSpec()) << "SolarEdge: found battery for inverter: calculate actual PV power from battery DC power and inverter DC power..."; + qCDebug(dcSunSpec()) << "--> SolarEdge: -------------------------------------------------------"; + qCDebug(dcSunSpec()) << "--> SolarEdge: meter power:" << meterCurrentPower << "W"; + qCDebug(dcSunSpec()) << "--> SolarEdge: inverter AC power:" << acPower << "W"; + qCDebug(dcSunSpec()) << "--> SolarEdge: inverter DC power:" << dcPower << "W"; + qCDebug(dcSunSpec()) << "--> SolarEdge: battery DC power:" << battery->batteryData().instantaneousPower << "W"; + qCDebug(dcSunSpec()) << "--> SolarEdge: battery DC state:" << battery->batteryData().batteryStatus; + + switch (battery->batteryData().batteryStatus) { + case SolarEdgeBattery::BatteryStatus::Charge: { + // Actual PV = inverter AC power - battery power + pvPower = acPower - battery->batteryData().instantaneousPower; + qCDebug(dcSunSpec()) << "--> SolarEdge: calculate actual PV power: inverter AC power - battery power:" << acPower << "-" << battery->batteryData().instantaneousPower << "=" << pvPower; + break; + } + case SolarEdgeBattery::BatteryStatus::Discharge: { + // Actual PV = inverter DC power + battery power + pvPower = dcPower - battery->batteryData().instantaneousPower; + qCDebug(dcSunSpec()) << "--> SolarEdge: calculate actual PV power: inverter DC power - battery power:" << dcPower << "-" << battery->batteryData().instantaneousPower << "=" << pvPower; + if (pvPower > 0) { + qCDebug(dcSunSpec()) << "--> SolarEdge: actual PV power: 0W | loss:" << pvPower << "W"; + pvPower = 0; + } + break; + } + default: + // Idle: No change required, AC power is actual PV energy + pvPower = acPower; + break; + } + + // Calculate self consumption + double selfConsumption = pvPower - meterCurrentPower + battery->batteryData().instantaneousPower; + qCDebug(dcSunSpec()) << "--> SolarEdge: self consumption" << selfConsumption << "W"; + } + } + + return pvPower; +} + +void IntegrationPluginSunSpec::autocreateSunSpecModelThing(const ThingClassId &thingClassId, const QString &thingName, const ThingId &parentId, SunSpecModel *model) +{ + /* On some systems we get duplicated things having the same serial number but are on different registers. + * Those devices normally dissapear after some time again. For now, we need to prevent to even create those + * devices by filtering out those who already have been added using the serialnumber, not only the combination + * of model number and start address. */ + + QString serialNumber = model->commonModelInfo().serialNumber; + if (!serialNumber.isEmpty()) { + // If we have any model with this exact serial number, we skip adding this model + foreach (Thing *thing, myThings()) { + if (m_serialNumberParamTypeIds.contains(thing->thingClassId())) { + QString thingSerialNumber = thing->paramValue(m_serialNumberParamTypeIds.value(thing->thingClassId())).toString(); + if (serialNumber == thingSerialNumber) { + qCWarning(dcSunSpec()) << "Trying to add a new thing for a SunSpec model, but we already have a thing with this serial number. Not adding it to the system..."; + qCWarning(dcSunSpec()) << "--> Already added:" << thing << thing->params(); + qCWarning(dcSunSpec()) << "--> Attempt to add:" << thingName << model + << model->commonModelInfo().modelName + << model->commonModelInfo().manufacturerName + << model->commonModelInfo().serialNumber + << model->commonModelInfo().versionString; + + return; + } + } + } + } + + ThingDescriptor descriptor(thingClassId); + descriptor.setParentId(parentId); + QString finalThingName; + if (model->commonModelInfo().manufacturerName.isEmpty()) { + finalThingName = thingName; + } else { + finalThingName = model->commonModelInfo().manufacturerName + " " + thingName; + } + + descriptor.setTitle(finalThingName); + + ParamList params; + params.append(Param(m_modelIdParamTypeIds.value(descriptor.thingClassId()), model->modelId())); + params.append(Param(m_modbusAddressParamTypeIds.value(descriptor.thingClassId()), model->modbusStartRegister())); + params.append(Param(m_manufacturerParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().manufacturerName)); + params.append(Param(m_deviceModelParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().modelName)); + params.append(Param(m_serialNumberParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().serialNumber)); + descriptor.setParams(params); + emit autoThingsAppeared({descriptor}); +} + +QString IntegrationPluginSunSpec::getInverterStateString(quint16 state) +{ + // Note: this works because the models have all the same value + QString stateString; + switch (state) { + case SunSpecInverterSinglePhaseModel::StOff: + stateString = "Off"; + break; + case SunSpecInverterSinglePhaseModel::StSleeping: + stateString = "Sleeping"; + break; + case SunSpecInverterSinglePhaseModel::StStarting: + stateString = "Starting"; + break; + case SunSpecInverterSinglePhaseModel::StMppt: + stateString = "MPPT"; + break; + case SunSpecInverterSinglePhaseModel::StThrottled: + stateString = "Throttled"; + break; + case SunSpecInverterSinglePhaseModel::StShuttingDown: + stateString = "Shutting down"; + break; + case SunSpecInverterSinglePhaseModel::StFault: + stateString = "Fault"; + break; + case SunSpecInverterSinglePhaseModel::StStandby: + stateString = "Standby"; + break; + } + return stateString; +} + +QString IntegrationPluginSunSpec::getInverterErrorString(quint32 flag) +{ + // Note: this works because the models have all the same value + QStringList errorStrings; + SunSpecInverterSinglePhaseModel::Evt1Flags event1Flag = static_cast(flag); + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1GroundFault)) { + errorStrings.append(QT_TR_NOOP("Ground fault")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1DcOverVolt)) { + errorStrings.append(QT_TR_NOOP("DC over voltage")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1AcDisconnect)) { + errorStrings.append(QT_TR_NOOP("AC disconnect")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1DcDisconnect)) { + errorStrings.append(QT_TR_NOOP("DC disconnect")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1GridDisconnect)) { + errorStrings.append(QT_TR_NOOP("Grid disconnect")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1CabinetOpen)) { + errorStrings.append(QT_TR_NOOP("Cabinet open")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1ManualShutdown)) { + errorStrings.append(QT_TR_NOOP("Manual shutdown")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1OverTemp)) { + errorStrings.append(QT_TR_NOOP("Over temperature")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1OverFrequency)) { + errorStrings.append(QT_TR_NOOP("Over frequency")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1UnderFrequency)) { + errorStrings.append(QT_TR_NOOP("Under frequency")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1AcOverVolt)) { + errorStrings.append(QT_TR_NOOP("AC over voltage")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1AcUnderVolt)) { + errorStrings.append(QT_TR_NOOP("AC under voltage")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1BlownStringFuse)) { + errorStrings.append(QT_TR_NOOP("Blown string fuse")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1UnderTemp)) { + errorStrings.append(QT_TR_NOOP("Under temperature")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1MemoryLoss)) { + errorStrings.append(QT_TR_NOOP("Memory loss")); + } + + if (event1Flag.testFlag(SunSpecInverterSinglePhaseModel::Evt1HwTestFailure)) { + errorStrings.append(QT_TR_NOOP("Hardware test failure")); + } + + if (errorStrings.isEmpty()) { + return QT_TR_NOOP("No error"); + } else { + return errorStrings.join(", "); + } +} + +double IntegrationPluginSunSpec::fixValueSign(double targetValue, double powerValue) +{ + // Some sunspec devices (i.e. SolarEdge return an absolute value on the phase current. + // This method makes sure the phase current has the same sign as the phase power value. + bool sameSign = ((targetValue < 0) == (powerValue < 0)); + return (sameSign ? targetValue : -targetValue); +} + +bool IntegrationPluginSunSpec::hasManufacturer(const QStringList &manufacturers, const QString &manufacturer) +{ + foreach (const QString &name, manufacturers) { + if (name.toLower().contains(manufacturer.toLower())) { + return true; + } + } + + return false; +} + +void IntegrationPluginSunSpec::markThingStatesDisconnected(Thing *thing) +{ + qCDebug(dcSunSpec()) << thing << "is now disconnected. Setting energy live data to 0."; + if (thing->thingClassId() == sunspecSinglePhaseInverterThingClassId) { + thing->setStateValue(sunspecSinglePhaseInverterConnectedStateTypeId, false); + thing->setStateValue(sunspecSinglePhaseInverterCurrentPowerStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseInverterTotalCurrentStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseInverterFrequencyStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseInverterPhaseVoltageStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseInverterVoltageDcStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseInverterCurrentDcStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseInverterCurrentPowerDcStateTypeId, 0); + } else if (thing->thingClassId() == sunspecSplitPhaseInverterThingClassId) { + thing->setStateValue(sunspecSplitPhaseInverterConnectedStateTypeId, false); + thing->setStateValue(sunspecSplitPhaseInverterCurrentPowerStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterTotalCurrentStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterFrequencyStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterPhaseANVoltageStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterPhaseBNVoltageStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterPhaseACurrentStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterPhaseBCurrentStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterVoltageDcStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterCurrentDcStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseInverterCurrentPowerDcStateTypeId, 0); + } else if (thing->thingClassId() == sunspecThreePhaseInverterThingClassId) { + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecThreePhaseInverterConnectedStateTypeId, false); + thing->setStateValue(sunspecThreePhaseInverterCurrentPowerStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterTotalCurrentStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterFrequencyStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterPhaseANVoltageStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterPhaseBNVoltageStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterPhaseCNVoltageStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterPhaseACurrentStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterPhaseBCurrentStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterPhaseCCurrentStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterVoltageDcStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterCurrentDcStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseInverterCurrentPowerDcStateTypeId, 0); + } else if (thing->thingClassId() == sunspecSinglePhaseMeterThingClassId) { + thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, false); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, 0); + thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, 0); + } else if (thing->thingClassId() == sunspecSplitPhaseMeterThingClassId) { + thing->setStateValue(sunspecSplitPhaseMeterConnectedStateTypeId, false); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, 0); + thing->setStateValue(sunspecSplitPhaseMeterFrequencyStateTypeId,0 ); + } else if (thing->thingClassId() == sunspecThreePhaseMeterThingClassId) { + thing->setStateValue(sunspecThreePhaseMeterConnectedStateTypeId, false); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, 0); + thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, 0); + } else if (thing->thingClassId() == sunspecStorageThingClassId) { + thing->setStateValue(sunspecStorageConnectedStateTypeId, false); + thing->setStateValue(sunspecStorageGridChargingStateTypeId, false); + thing->setStateValue(sunspecStorageChargingRateStateTypeId, 0); + thing->setStateValue(sunspecStorageDischargingRateStateTypeId, 0); + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Off"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "idle"); + } else if (thing->thingClassId() == solarEdgeBatteryThingClassId) { + thing->setStateValue(solarEdgeBatteryConnectedStateTypeId, false); + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Idle"); + thing->setStateValue(solarEdgeBatteryChargingStateStateTypeId, "idle"); + thing->setStateValue(solarEdgeBatteryInstantaneousVoltageStateTypeId, 0); + thing->setStateValue(solarEdgeBatteryInstantaneousCurrentStateTypeId, 0); + thing->setStateValue(solarEdgeBatteryCurrentPowerStateTypeId, 0); + } + +} + +void IntegrationPluginSunSpec::onRefreshTimer() +{ + // Update meters + foreach (SunSpecModel *model, m_sunSpecMeters.values()) { + if (model->connection()->connected()) { + model->readBlockData(); + } + } + + // Update storage + foreach (SunSpecModel *model, m_sunSpecStorages.values()) { + if (model->connection()->connected()) { + model->readBlockData(); + } + } + + // Update all other sunspec thing blocks + foreach (SunSpecThing *sunSpecThing, m_sunSpecThings) { + if (sunSpecThing->connection()->connected()) { + sunSpecThing->readBlockData(); + } + } + + // Update inverters + foreach (SunSpecModel *model, m_sunSpecInverters.values()) { + if (model->connection()->connected()) { + model->readBlockData(); + } + } +} + +void IntegrationPluginSunSpec::onPluginConfigurationChanged(const ParamTypeId ¶mTypeId, const QVariant &value) +{ + // Check refresh schedule + if (paramTypeId == sunSpecPluginUpdateIntervalParamTypeId) { + qCDebug(dcSunSpec()) << "Update interval has changed" << value.toInt(); + if (m_refreshTimer) { + int refreshTime = value.toInt(); + m_refreshTimer->stop(); + hardwareManager()->pluginTimerManager()->unregisterTimer(m_refreshTimer); + m_refreshTimer = hardwareManager()->pluginTimerManager()->registerTimer(refreshTime); + connect(m_refreshTimer, &PluginTimer::timeout, this, &IntegrationPluginSunSpec::onRefreshTimer); + m_refreshTimer->start(); + } + } else if (paramTypeId == sunSpecPluginNumberOfRetriesParamTypeId) { + qCDebug(dcSunSpec()) << "Updating number of retries" << value.toUInt(); + foreach (SunSpecConnection *connection, m_sunSpecConnections) { + connection->setNumberOfRetries(value.toUInt()); + } + } else if (paramTypeId == sunSpecPluginTimeoutParamTypeId) { + qCDebug(dcSunSpec()) << "Updating timeout" << value.toUInt() << "[ms]"; + foreach (SunSpecConnection *connection, m_sunSpecConnections) { + connection->setTimeout(value.toUInt()); + } + } else { + qCWarning(dcSunSpec()) << "Unknown plugin configuration" << paramTypeId << "Value" << value; + } +} + +void IntegrationPluginSunSpec::onInverterBlockUpdated() +{ + SunSpecModel *model = qobject_cast(sender()); + Thing *thing = m_sunSpecInverters.key(model); + if (!thing) return; + + // Get parent thing + Thing *parentThing = myThings().findById(thing->parentId()); + if (!parentThing) + return; + + switch (model->modelId()) { + case SunSpecModelFactory::ModelIdInverterSinglePhase: { + SunSpecInverterSinglePhaseModel *inverter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecSinglePhaseInverterConnectedStateTypeId, true); + thing->setStateValue(sunspecSinglePhaseInverterVersionStateTypeId, model->commonModelInfo().versionString); + + // Note: solar edge needs some calculations for the current pv power + double currentPower = calculateSolarEdgePvProduction(thing, -inverter->watts(), -inverter->dcWatts()); + thing->setStateValue(sunspecSinglePhaseInverterCurrentPowerStateTypeId, currentPower); + evaluateEnergyProducedValue(thing, inverter->wattHours() / 1000.0); + thing->setStateValue(sunspecSinglePhaseInverterTotalCurrentStateTypeId, inverter->amps()); + thing->setStateValue(sunspecSinglePhaseInverterFrequencyStateTypeId, inverter->hz()); + thing->setStateValue(sunspecSinglePhaseInverterCabinetTemperatureStateTypeId, inverter->cabinetTemperature()); + thing->setStateValue(sunspecSinglePhaseInverterPhaseVoltageStateTypeId, inverter->phaseVoltageAn()); + thing->setStateValue(sunspecSinglePhaseInverterOperatingStateStateTypeId, getInverterStateString(inverter->operatingState())); + thing->setStateValue(sunspecSinglePhaseInverterErrorStateTypeId, getInverterErrorString(inverter->event1())); + thing->setStateValue(sunspecSinglePhaseInverterVoltageDcStateTypeId, inverter->dcVoltage()); + thing->setStateValue(sunspecSinglePhaseInverterCurrentDcStateTypeId, inverter->dcAmps()); + thing->setStateValue(sunspecSinglePhaseInverterCurrentPowerDcStateTypeId, -inverter->dcWatts()); + break; + } + case SunSpecModelFactory::ModelIdInverterSinglePhaseFloat: { + SunSpecInverterSinglePhaseFloatModel *inverter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecSinglePhaseInverterConnectedStateTypeId, true); + thing->setStateValue(sunspecSinglePhaseInverterVersionStateTypeId, model->commonModelInfo().versionString); + + // Note: solar edge needs some calculations for the current pv power + double currentPower = calculateSolarEdgePvProduction(thing, -inverter->watts(), -inverter->dcWatts()); + thing->setStateValue(sunspecSinglePhaseInverterCurrentPowerStateTypeId, currentPower); + evaluateEnergyProducedValue(thing, inverter->wattHours() / 1000.0); + thing->setStateValue(sunspecSinglePhaseInverterTotalCurrentStateTypeId, inverter->amps()); + thing->setStateValue(sunspecSinglePhaseInverterFrequencyStateTypeId, inverter->hz()); + thing->setStateValue(sunspecSinglePhaseInverterCabinetTemperatureStateTypeId, inverter->cabinetTemperature()); + thing->setStateValue(sunspecSinglePhaseInverterPhaseVoltageStateTypeId, inverter->phaseVoltageAn()); + thing->setStateValue(sunspecSinglePhaseInverterOperatingStateStateTypeId, getInverterStateString(inverter->operatingState())); + thing->setStateValue(sunspecSinglePhaseInverterErrorStateTypeId, getInverterErrorString(inverter->event1())); + thing->setStateValue(sunspecSinglePhaseInverterVoltageDcStateTypeId, inverter->dcVoltage()); + thing->setStateValue(sunspecSinglePhaseInverterCurrentDcStateTypeId, inverter->dcAmps()); + thing->setStateValue(sunspecSinglePhaseInverterCurrentPowerDcStateTypeId, -inverter->dcWatts()); + break; + } + case SunSpecModelFactory::ModelIdInverterSplitPhase: { + SunSpecInverterSplitPhaseModel *inverter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecSplitPhaseInverterConnectedStateTypeId, true); + thing->setStateValue(sunspecSplitPhaseInverterVersionStateTypeId, model->commonModelInfo().versionString); + + double currentPower = calculateSolarEdgePvProduction(thing, -inverter->watts(), -inverter->dcWatts()); + thing->setStateValue(sunspecSplitPhaseInverterCurrentPowerStateTypeId, currentPower); + evaluateEnergyProducedValue(thing, inverter->wattHours() / 1000.0); + thing->setStateValue(sunspecSplitPhaseInverterTotalCurrentStateTypeId, inverter->amps()); + thing->setStateValue(sunspecSplitPhaseInverterFrequencyStateTypeId, inverter->hz()); + thing->setStateValue(sunspecSplitPhaseInverterCabinetTemperatureStateTypeId, inverter->cabinetTemperature()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseANVoltageStateTypeId, inverter->phaseVoltageAn()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseBNVoltageStateTypeId, inverter->phaseVoltageBn()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseACurrentStateTypeId, inverter->ampsPhaseA()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseBCurrentStateTypeId, inverter->ampsPhaseB()); + thing->setStateValue(sunspecSplitPhaseInverterOperatingStateStateTypeId, getInverterStateString(inverter->operatingState())); + thing->setStateValue(sunspecSplitPhaseInverterErrorStateTypeId, getInverterErrorString(inverter->event1())); + thing->setStateValue(sunspecSplitPhaseInverterVoltageDcStateTypeId, inverter->dcVoltage()); + thing->setStateValue(sunspecSplitPhaseInverterCurrentDcStateTypeId, inverter->dcAmps()); + thing->setStateValue(sunspecSplitPhaseInverterCurrentPowerDcStateTypeId, -inverter->dcWatts()); + break; + } + case SunSpecModelFactory::ModelIdInverterSplitPhaseFloat: { + SunSpecInverterSplitPhaseFloatModel *inverter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecSplitPhaseInverterConnectedStateTypeId, true); + thing->setStateValue(sunspecSplitPhaseInverterVersionStateTypeId, model->commonModelInfo().versionString); + + double currentPower = calculateSolarEdgePvProduction(thing, -inverter->watts(), -inverter->dcWatts()); + thing->setStateValue(sunspecSplitPhaseInverterCurrentPowerStateTypeId, currentPower); + evaluateEnergyProducedValue(thing, inverter->wattHours() / 1000.0); + thing->setStateValue(sunspecSplitPhaseInverterTotalCurrentStateTypeId, inverter->amps()); + thing->setStateValue(sunspecSplitPhaseInverterFrequencyStateTypeId, inverter->hz()); + thing->setStateValue(sunspecSplitPhaseInverterCabinetTemperatureStateTypeId, inverter->cabinetTemperature()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseANVoltageStateTypeId, inverter->phaseVoltageAn()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseBNVoltageStateTypeId, inverter->phaseVoltageBn()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseACurrentStateTypeId, inverter->ampsPhaseA()); + thing->setStateValue(sunspecSplitPhaseInverterPhaseBCurrentStateTypeId, inverter->ampsPhaseB()); + thing->setStateValue(sunspecSplitPhaseInverterOperatingStateStateTypeId, getInverterStateString(inverter->operatingState())); + thing->setStateValue(sunspecSplitPhaseInverterErrorStateTypeId, getInverterErrorString(inverter->event1())); + thing->setStateValue(sunspecSplitPhaseInverterVoltageDcStateTypeId, inverter->dcVoltage()); + thing->setStateValue(sunspecSplitPhaseInverterCurrentDcStateTypeId, inverter->dcAmps()); + thing->setStateValue(sunspecSplitPhaseInverterCurrentPowerDcStateTypeId, -inverter->dcWatts()); + break; + } + case SunSpecModelFactory::ModelIdInverterThreePhase: { + SunSpecInverterThreePhaseModel *inverter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecThreePhaseInverterConnectedStateTypeId, true); + thing->setStateValue(sunspecThreePhaseInverterVersionStateTypeId, model->commonModelInfo().versionString); + + double currentPower = calculateSolarEdgePvProduction(thing, -inverter->watts(), -inverter->dcWatts()); + thing->setStateValue(sunspecThreePhaseInverterCurrentPowerStateTypeId, currentPower); + evaluateEnergyProducedValue(thing, inverter->wattHours() / 1000.0); + thing->setStateValue(sunspecThreePhaseInverterTotalCurrentStateTypeId, inverter->amps()); + thing->setStateValue(sunspecThreePhaseInverterFrequencyStateTypeId, inverter->hz()); + thing->setStateValue(sunspecThreePhaseInverterCabinetTemperatureStateTypeId, inverter->cabinetTemperature()); + thing->setStateValue(sunspecThreePhaseInverterPhaseANVoltageStateTypeId, inverter->phaseVoltageAn()); + thing->setStateValue(sunspecThreePhaseInverterPhaseBNVoltageStateTypeId, inverter->phaseVoltageBn()); + thing->setStateValue(sunspecThreePhaseInverterPhaseCNVoltageStateTypeId, inverter->phaseVoltageCn()); + thing->setStateValue(sunspecThreePhaseInverterPhaseACurrentStateTypeId, inverter->ampsPhaseA()); + thing->setStateValue(sunspecThreePhaseInverterPhaseBCurrentStateTypeId, inverter->ampsPhaseB()); + thing->setStateValue(sunspecThreePhaseInverterPhaseCCurrentStateTypeId, inverter->ampsPhaseC()); + thing->setStateValue(sunspecThreePhaseInverterOperatingStateStateTypeId, getInverterStateString(inverter->operatingState())); + thing->setStateValue(sunspecThreePhaseInverterErrorStateTypeId, getInverterErrorString(inverter->event1())); + thing->setStateValue(sunspecThreePhaseInverterVoltageDcStateTypeId, inverter->dcVoltage()); + thing->setStateValue(sunspecThreePhaseInverterCurrentDcStateTypeId, inverter->dcAmps()); + thing->setStateValue(sunspecThreePhaseInverterCurrentPowerDcStateTypeId, -inverter->dcWatts()); + break; + } + case SunSpecModelFactory::ModelIdInverterThreePhaseFloat: { + SunSpecInverterThreePhaseFloatModel *inverter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << inverter; + thing->setStateValue(sunspecThreePhaseInverterConnectedStateTypeId, true); + thing->setStateValue(sunspecThreePhaseInverterVersionStateTypeId, model->commonModelInfo().versionString); + + double currentPower = calculateSolarEdgePvProduction(thing, -inverter->watts(), -inverter->dcWatts()); + thing->setStateValue(sunspecThreePhaseInverterCurrentPowerStateTypeId, currentPower); + evaluateEnergyProducedValue(thing, inverter->wattHours() / 1000.0); + thing->setStateValue(sunspecThreePhaseInverterTotalCurrentStateTypeId, inverter->amps()); + thing->setStateValue(sunspecThreePhaseInverterFrequencyStateTypeId, inverter->hz()); + thing->setStateValue(sunspecThreePhaseInverterCabinetTemperatureStateTypeId, inverter->cabinetTemperature()); + thing->setStateValue(sunspecThreePhaseInverterPhaseANVoltageStateTypeId, inverter->phaseVoltageAn()); + thing->setStateValue(sunspecThreePhaseInverterPhaseBNVoltageStateTypeId, inverter->phaseVoltageBn()); + thing->setStateValue(sunspecThreePhaseInverterPhaseCNVoltageStateTypeId, inverter->phaseVoltageCn()); + thing->setStateValue(sunspecThreePhaseInverterPhaseACurrentStateTypeId, inverter->ampsPhaseA()); + thing->setStateValue(sunspecThreePhaseInverterPhaseBCurrentStateTypeId, inverter->ampsPhaseB()); + thing->setStateValue(sunspecThreePhaseInverterPhaseCCurrentStateTypeId, inverter->ampsPhaseC()); + thing->setStateValue(sunspecThreePhaseInverterOperatingStateStateTypeId, getInverterStateString(inverter->operatingState())); + thing->setStateValue(sunspecThreePhaseInverterErrorStateTypeId, getInverterErrorString(inverter->event1())); + thing->setStateValue(sunspecThreePhaseInverterVoltageDcStateTypeId, inverter->dcVoltage()); + thing->setStateValue(sunspecThreePhaseInverterCurrentDcStateTypeId, inverter->dcAmps()); + thing->setStateValue(sunspecThreePhaseInverterCurrentPowerDcStateTypeId, -inverter->dcWatts()); + break; + } + default: + qCWarning(dcSunSpec()) << "Received block data from unhandled model" << model; + break; + } +} + +void IntegrationPluginSunSpec::onMeterBlockUpdated() +{ + SunSpecModel *model = qobject_cast(sender()); + Thing *thing = m_sunSpecMeters.key(model); + if (!thing) return; + + // Get parent thing + Thing *parentThing = myThings().findById(thing->parentId()); + if (!parentThing) + return; + + switch (model->modelId()) { + case SunSpecModelFactory::ModelIdMeterSinglePhase: { + SunSpecMeterSinglePhaseModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, -meter->ampsPhaseA()); + thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdMeterSinglePhaseFloat: { + SunSpecMeterSinglePhaseFloatModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecSinglePhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecSinglePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecSinglePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->watts())); + thing->setStateValue(sunspecSinglePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecSinglePhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecSinglePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdMeterSplitSinglePhaseAbn: { + SunSpecMeterSplitSinglePhaseAbnModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecSplitPhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecSplitPhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseAStateTypeId, meter->totalWattHoursImportedPhaseA() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn()); + thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); + thing->setStateValue(sunspecSplitPhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecSplitPhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdMeterSplitSinglePhaseFloat: { + SunSpecMeterSplitSinglePhaseFloatModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecSplitPhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecSplitPhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseAStateTypeId, meter->totalWattHoursImportedPhaseA() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecSplitPhaseMeterTotalCurrentStateTypeId, fixValueSign(meter->amps(), -meter->watts())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); + thing->setStateValue(sunspecSplitPhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecSplitPhaseMeterLnACVoltageStateTypeId, meter->voltageLn()); + thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecSplitPhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); + thing->setStateValue(sunspecSplitPhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecSplitPhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdMeterThreePhase: { + SunSpecMeterThreePhaseModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecThreePhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseAStateTypeId, meter->totalWattHoursImportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseCStateTypeId, meter->totalWattHoursImportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); + thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecThreePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdDeltaConnectThreePhaseAbcMeter: { + SunSpecDeltaConnectThreePhaseAbcMeterModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecThreePhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseAStateTypeId, meter->totalWattHoursImportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseCStateTypeId, meter->totalWattHoursImportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); + thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecThreePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdMeterThreePhaseWyeConnect: { + SunSpecMeterThreePhaseWyeConnectModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecThreePhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseAStateTypeId, meter->totalWattHoursImportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseCStateTypeId, meter->totalWattHoursImportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); + thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecThreePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + case SunSpecModelFactory::ModelIdMeterThreePhaseDeltaConnect: { + SunSpecMeterThreePhaseDeltaConnectModel *meter = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << meter; + thing->setStateValue(sunspecThreePhaseMeterConnectedStateTypeId, true); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyProducedStateTypeId, meter->totalWattHoursExported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterTotalEnergyConsumedStateTypeId, meter->totalWattHoursImported() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseAStateTypeId, meter->totalWattHoursImportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseBStateTypeId, meter->totalWattHoursImportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyConsumedPhaseCStateTypeId, meter->totalWattHoursImportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseAStateTypeId, meter->totalWattHoursExportedPhaseA() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseBStateTypeId, meter->totalWattHoursExportedPhaseB() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterEnergyProducedPhaseCStateTypeId, meter->totalWattHoursExportedPhaseC() / 1000.0); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerStateTypeId, -meter->watts()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseAStateTypeId, -meter->wattsPhaseA()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseBStateTypeId, -meter->wattsPhaseB()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPowerPhaseCStateTypeId, -meter->wattsPhaseC()); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseAStateTypeId, fixValueSign(meter->ampsPhaseA(), -meter->wattsPhaseA())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseBStateTypeId, fixValueSign(meter->ampsPhaseB(), -meter->wattsPhaseB())); + thing->setStateValue(sunspecThreePhaseMeterCurrentPhaseCStateTypeId, fixValueSign(meter->ampsPhaseC(), -meter->wattsPhaseC())); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseAStateTypeId, meter->phaseVoltageAn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseBStateTypeId, meter->phaseVoltageBn()); + thing->setStateValue(sunspecThreePhaseMeterVoltagePhaseCStateTypeId, meter->phaseVoltageCn()); + thing->setStateValue(sunspecThreePhaseMeterFrequencyStateTypeId, meter->hz()); + thing->setStateValue(sunspecThreePhaseMeterVersionStateTypeId, model->commonModelInfo().versionString); + break; + } + default: + qCWarning(dcSunSpec()) << "Received block data from unhandled model" << model; + break; + } +} + +void IntegrationPluginSunSpec::onStorageBlockUpdated() +{ + SunSpecModel *model = qobject_cast(sender()); + Thing *thing = m_sunSpecStorages.key(model); + if (!thing) return; + + SunSpecStorageModel *storage = qobject_cast(model); + qCDebug(dcSunSpec()) << thing->name() << "block data updated";// << storage; + + thing->setStateValue(sunspecStorageConnectedStateTypeId, true); + thing->setStateValue(sunspecStorageVersionStateTypeId, model->commonModelInfo().versionString); + + thing->setStateValue(sunspecStorageBatteryCriticalStateTypeId, storage->chaState() < 5); + thing->setStateValue(sunspecStorageBatteryLevelStateTypeId, qRound(storage->chaState())); + thing->setStateValue(sunspecStorageGridChargingStateTypeId, storage->chaGriSet() == SunSpecStorageModel::ChagrisetGrid); + thing->setStateValue(sunspecStorageEnableChargingStateTypeId, storage->storCtlMod().testFlag(SunSpecStorageModel::Storctl_modCharge)); + thing->setStateValue(sunspecStorageChargingRateStateTypeId, storage->wChaGra()); + thing->setStateValue(sunspecStorageDischargingRateStateTypeId, storage->wDisChaGra()); + + switch (storage->chaSt()) { + case SunSpecStorageModel::ChastOff: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Off"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "idle"); + break; + case SunSpecStorageModel::ChastEmpty: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Empty"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "idle"); + break; + case SunSpecStorageModel::ChastDischarging: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Discharging"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "discharging"); + break; + case SunSpecStorageModel::ChastCharging: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Charging"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "charging"); + break; + case SunSpecStorageModel::ChastFull: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Full"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "idle"); + break; + case SunSpecStorageModel::ChastHolding: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Holding"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "idle"); + break; + case SunSpecStorageModel::ChastTesting: + thing->setStateValue(sunspecStorageStorageStatusStateTypeId, "Testing"); + thing->setStateValue(sunspecStorageChargingStateStateTypeId, "idle"); + break; + } +} + +void IntegrationPluginSunSpec::onSolarEdgeBatteryBlockUpdated() +{ + SolarEdgeBattery *battery = qobject_cast(sender()); + Thing *thing = battery->thing(); + + qCDebug(dcSunSpec()) << "SolarEdgeBattery: block data updated";// << battery->batteryData(); + + thing->setStateValue(solarEdgeBatteryConnectedStateTypeId, true); + + QString chargingState = "idle"; + switch (battery->batteryData().batteryStatus) { + case SolarEdgeBattery::Off: + chargingState = "idle"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Off"); + break; + case SolarEdgeBattery::Standby: + chargingState = "idle"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Standby"); + break; + case SolarEdgeBattery::Init: + chargingState = "idle"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Init"); + break; + case SolarEdgeBattery::Charge: + chargingState = "charging"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Charging"); + break; + case SolarEdgeBattery::Discharge: + chargingState = "discharging"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Discharging"); + break; + case SolarEdgeBattery::Fault: + chargingState = "idle"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Fault"); + break; + case SolarEdgeBattery::Holding: + chargingState = "idle"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Holding"); + break; + case SolarEdgeBattery::Idle: + chargingState = "idle"; + thing->setStateValue(solarEdgeBatteryBatteryStatusStateTypeId, "Idle"); + break; + } + + thing->setStateValue(solarEdgeBatteryBatteryCriticalStateTypeId, (battery->batteryData().stateOfEnergy < 5) && chargingState != "charging"); + thing->setStateValue(solarEdgeBatteryBatteryLevelStateTypeId, battery->batteryData().stateOfEnergy); + thing->setStateValue(solarEdgeBatteryChargingStateStateTypeId, chargingState); + thing->setStateValue(solarEdgeBatteryRatedEnergyStateTypeId, battery->batteryData().ratedEnergy / 1000.0); // kWh + thing->setStateValue(solarEdgeBatteryAverageTemperatureStateTypeId, battery->batteryData().averageTemperature); + thing->setStateValue(solarEdgeBatteryInstantaneousVoltageStateTypeId, battery->batteryData().instantaneousVoltage); + thing->setStateValue(solarEdgeBatteryInstantaneousCurrentStateTypeId, battery->batteryData().instantaneousCurrent); + thing->setStateValue(solarEdgeBatteryCurrentPowerStateTypeId, battery->batteryData().instantaneousPower); + thing->setStateValue(solarEdgeBatteryMaxEnergyStateTypeId, battery->batteryData().maxEnergy / 1000.0); // kWh + thing->setStateValue(solarEdgeBatteryCapacityStateTypeId, battery->batteryData().availableEnergy / 1000.0); // kWh + thing->setStateValue(solarEdgeBatteryStateOfHealthStateTypeId, battery->batteryData().stateOfHealth); + thing->setStateValue(solarEdgeBatteryVersionStateTypeId, battery->batteryData().firmwareVersion); +} + +void IntegrationPluginSunSpec::evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced) +{ + /* Note: on some systems the inverter sends for a longer period an absurdly + * high and wrong value for the produced energy (seen so far with SolarEdge inverters). + * + * In order to catch such situations, we need to verify if the state changed makes sense, + * or if the difference is to big for a regular produced energy value. + * + * Following scenarios need to be considered: + * - This is the first data value, we have no history to verify if this values makes sense + * - The system might be switched off for some time, the energy produced could be much more than the last known value + * - More than one value in a row could occure, not only single garbage data value + */ + + StateTypeId energyProducedStateTypeId; + if (inverterThing->thingClassId() == sunspecSinglePhaseInverterThingClassId) { + energyProducedStateTypeId = sunspecSinglePhaseInverterTotalEnergyProducedStateTypeId; + } else if (inverterThing->thingClassId() == sunspecSplitPhaseInverterThingClassId) { + energyProducedStateTypeId = sunspecSplitPhaseInverterTotalEnergyProducedStateTypeId; + } else if (inverterThing->thingClassId() == sunspecThreePhaseInverterThingClassId) { + energyProducedStateTypeId = sunspecThreePhaseInverterTotalEnergyProducedStateTypeId; + } else { + qCWarning(dcSunSpec()) << "Could not evaluate energy produced value for ThingClassId" << inverterThing->thingClassId() << "The value will not be updated."; + return; + } + + double currentEnergyValue = inverterThing->stateValue(energyProducedStateTypeId).toDouble(); + if (currentEnergyValue <= 0) { + // Probably the initial value, no fancy data handling here + inverterThing->setStateValue(energyProducedStateTypeId, energyProduced); + } else { + double producedDiff = energyProduced - currentEnergyValue; + if (producedDiff > 10000 /*kWh*/) { + // The new energy value is way to high in order to be a reglar energy produced change... + qCWarning(dcSunSpec()) << "The energy produced value for" << inverterThing << "is way to high compared to the previouse value:" + << currentEnergyValue << "kWh. Ignoring the value:" << energyProduced << "kWh."; + return; + } else { + // Not a huge jump, just set the value + inverterThing->setStateValue(energyProducedStateTypeId, energyProduced); + } + } +} diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h new file mode 100644 index 0000000..d6a09b4 --- /dev/null +++ b/sunspec/integrationpluginsunspec.h @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef INTEGRATIONPLUGINSUNSPEC_H +#define INTEGRATIONPLUGINSUNSPEC_H + +#include +#include +#include + +#include +#include + +#include "sunspecthing.h" +#include "extern-plugininfo.h" + +#include + +class IntegrationPluginSunSpec: public IntegrationPlugin +{ + Q_OBJECT + + Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginsunspec.json") + Q_INTERFACES(IntegrationPlugin) + +public: + explicit IntegrationPluginSunSpec(); + + void init() override; + void discoverThings(ThingDiscoveryInfo *info) override; + void setupThing(ThingSetupInfo *info) override; + void postSetupThing(Thing *thing) override; + void thingRemoved(Thing *thing) override; + void executeAction(ThingActionInfo *info) override; + +private: + // SunSpec Connection params map + QHash m_connectionDiscoverySlaveIdParamTypeIds; + QHash m_connectionMacAddressParamTypeIds; + QHash m_connectionAddressParamTypeIds; + QHash m_connectionHostNameParamTypeIds; + QHash m_connectionPortParamTypeIds; + QHash m_connectionSlaveIdParamTypeIds; + + // SunSpec thing params map + QHash m_modelIdParamTypeIds; + QHash m_modbusAddressParamTypeIds; + QHash m_manufacturerParamTypeIds; + QHash m_deviceModelParamTypeIds; + QHash m_serialNumberParamTypeIds; + + PluginTimer *m_refreshTimer = nullptr; + + QHash m_monitors; + + QHash m_sunSpecConnections; + QHash m_sunSpecThings; + + QHash m_sunSpecInverters; + QHash m_sunSpecMeters; + QHash m_sunSpecStorages; + + + Thing *getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId); + bool sunspecThingAlreadyAdded(uint modelId, uint modbusAddress, const ThingId &parentId); + void processDiscoveryResult(Thing *thing, SunSpecConnection *connection); + + // SunSpec things + SunSpecConnection *createConnection(Thing *thing); + + // Custom types + void setupSolarEdgeBattery(ThingSetupInfo *info); + + // Discover SolarEdge batteries + void searchSolarEdgeBatteries(SunSpecConnection *connection); + void searchSolarEdgeBattery(SunSpecConnection *connection, const ThingId &parentThingId, quint16 startRegister); + + // SolarEdge + double calculateSolarEdgePvProduction(Thing *thing, double acPower, double dcPower); + + void autocreateSunSpecModelThing(const ThingClassId &thingClassId, const QString &thingName, const ThingId &parentId, SunSpecModel *model); + + QString getInverterStateString(quint16 state); + QString getInverterErrorString(quint32 flag); + + double fixValueSign(double targetValue, double powerValue); + bool hasManufacturer(const QStringList &manufacturers, const QString &manufacturer); + void markThingStatesDisconnected(Thing *thing); + +private slots: + void onRefreshTimer(); + void onPluginConfigurationChanged(const ParamTypeId ¶mTypeId, const QVariant &value); + + void onInverterBlockUpdated(); + void onMeterBlockUpdated(); + void onStorageBlockUpdated(); + void onSolarEdgeBatteryBlockUpdated(); + + void evaluateEnergyProducedValue(Thing *inverterThing, float energyProduced); + + +}; +#endif // INTEGRATIONPLUGINSUNSPEC_H + diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json new file mode 100644 index 0000000..6696477 --- /dev/null +++ b/sunspec/integrationpluginsunspec.json @@ -0,0 +1,1728 @@ +{ + "name": "SunSpec", + "displayName": "sunspec", + "id": "cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b", + "paramTypes":[ + { + "id": "52da5222-9a94-47a2-9adc-004541d2f5ed", + "name": "updateInterval", + "displayName": "Update interval", + "type": "int", + "unit": "Seconds", + "defaultValue": 1 + }, + { + "id": "1a8895a0-c746-48af-9307-3a4636f24cc2", + "name": "timeout", + "displayName": "Timout", + "type": "uint", + "unit": "MilliSeconds", + "defaultValue": 1000 + }, + { + "id": "9a4bfe01-315f-4ee7-98a9-f16b08ba12ad", + "name": "numberOfRetries", + "displayName": "Number of retries", + "type": "uint", + "defaultValue": 3, + "minValue": 1, + "maxValue": 10 + } + ], + "vendors": [ + { + "name": "sunspec", + "displayName": "SunSpec", + "id": "c143a7b4-a16c-4fff-86a3-9ffab3d6841d", + "thingClasses": [ + { + "name": "sunspecConnection", + "displayName": "SunSpec Generic", + "id": "f51853f3-8815-4cf3-b337-45cda1f3e6d5", + "createMethods": [ "discovery", "user" ], + "interfaces": [ "gateway", "networkdevice" ], + "providedInterfaces": [ "solarinverter", "energymeter", "energystorage"], + "discoveryParamTypes": [ + { + "id": "50e027aa-ab15-44df-80d3-71964aee44af", + "name":"slaveId", + "displayName": "Slave ID", + "type": "int", + "defaultValue": 1 + } + ], + "paramTypes": [ + { + "id": "3567b389-9d42-48f9-a29b-d18388fb36a1", + "name": "address", + "displayName": "Host address", + "type": "QString", + "inputType": "IPv4Address", + "defaultValue": "" + }, + { + "id": "1667b0ed-9a2b-47c6-a01f-690caee55ffa", + "name": "hostName", + "displayName": "Host name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "f65d6c36-1672-44cb-b52a-62d71837ae67", + "name":"macAddress", + "displayName": "MAC address", + "type": "QString", + "inputType": "MacAddress", + "defaultValue": "" + }, + { + "id": "1fa4fc9c-f6be-47c7-928a-bcefc1142eec", + "name":"port", + "displayName": "Port", + "type": "int", + "defaultValue": 502 + }, + { + "id": "953064e0-4675-4538-a9a2-fa22ce2f347c", + "name":"slaveId", + "displayName": "Slave ID", + "type": "int", + "defaultValue": 1 + }, + { + "id": "38082ecf-23bf-47d1-beba-f816c978c823", + "name":"endianness", + "displayName": "Endianness", + "type": "QString", + "allowedValues": [ + "Big Endian", + "Little Endian" + ], + "defaultValue": "Big Endian" + } + ], + "stateTypes":[ + { + "id": "3e767ad0-b4b3-4398-94c1-00579ea09ca8", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + } + ] + }, + { + "name": "sunspecSinglePhaseInverter", + "displayName": "SunSpec single phase inverter", + "id": "c5d5204e-3375-4b92-8128-fab77a671fed", + "createMethods": [ "Auto" ], + "interfaces": [ "solarinverter", "connectable" ], + "paramTypes": [ + { + "id": "41715d00-a947-4f43-a475-cea05790e01d", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true + }, + { + "id": "26ae9050-7090-453a-85a3-307bfebe6fed", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "5fbb0b62-e0f9-4aee-aba7-fd00ae8b0a5d", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "12f1b02c-033d-423f-a217-955ddb995dd5", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "59b85218-f144-4ce8-95f7-19a1dc2e2a8d", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "48bf31c4-fda7-41e5-a3ef-3011bf96e104", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "f02d1c99-9b43-45a6-8a06-2ed4d6e5d497", + "name": "totalCurrent", + "displayName": "Total AC current", + "displayNameEvent": "Total AC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "4ca086e9-82b9-461c-b168-1d61b542b884", + "name": "phaseVoltage", + "displayName": "Phase voltage", + "displayNameEvent": "Phase volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "f49591d6-d759-4be3-bafc-b6a7a72cf023", + "name": "currentPower", + "displayName": "AC power", + "displayNameEvent": "AC power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "611df2ce-2b9c-49f3-9fa7-5706776e812c", + "name": "frequency", + "displayName": "Frequency", + "displayNameEvent": "Frequency changed", + "type": "double", + "unit": "Hertz", + "defaultValue": 0.00 + }, + { + "id": "4c0407b3-5cd5-438d-bfa8-9a8d6695b458", + "name": "totalEnergyProduced", + "displayName": "AC energy", + "displayNameEvent": "AC energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "157376b8-19ae-415f-b693-cb0a9ba88863", + "name": "voltageDc", + "displayName": "DC voltage", + "displayNameEvent": "DC volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "79bd1748-b540-467d-b6fa-b0cc56d9775a", + "name": "currentDc", + "displayName": "DC current", + "displayNameEvent": "DC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "852a8c28-b14d-492e-b2b3-f22219f4ed36", + "name": "currentPowerDc", + "displayName": "DC power", + "displayNameEvent": "DC power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "51461bde-1a6b-4aa1-94cc-59829ea0a7c8", + "name": "cabinetTemperature", + "displayName": "Cabinet temperature", + "displayNameEvent": "Cabinet temperature changed", + "type": "double", + "unit": "DegreeCelsius", + "defaultValue": 0.00 + }, + { + "id": "47543a7f-425f-406b-a458-b79c36b65f6c", + "name": "operatingState", + "displayName": "Operating state", + "displayNameEvent": "Operating state changed", + "type": "QString", + "possibleValues": [ + "Off", + "Sleeping", + "Starting", + "MPPT", + "Throttled", + "Shutting down", + "Fault", + "Standby" + ], + "defaultValue": "Off" + }, + { + "id": "49240259-d82a-4fe6-b3f5-1cd6a67c87a7", + "name": "error", + "displayName": "Error", + "displayNameEvent": "Error changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "f4439de3-a461-42d6-b4c9-aa35f696a420", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "name": "sunspecSplitPhaseInverter", + "displayName": "SunSpec split phase inverter", + "id": "61b38f93-d331-42bf-b1ef-d3fb16ad1230", + "createMethods": [ "Auto" ], + "interfaces": [ "solarinverter", "connectable" ], + "paramTypes": [ + { + "id": "c42fb50e-210f-4b53-88eb-fa216e15f88f", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true + }, + { + "id": "37582a96-f2f2-4845-abef-973c7dd0ad57", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "c2c35b68-1c0f-47cc-b09f-49d9cf67c0ff", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "d40b1b9d-994d-422c-8a41-b98cf03c1678", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "8afdd256-0393-4610-9477-7c4541c92ffe", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "27b49640-f58b-466e-a225-a4663cf3ed96", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "9dbd8da7-dc22-4c3a-b941-47520fde705f", + "name": "totalCurrent", + "displayName": "Total AC current", + "displayNameEvent": "Total AC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "be7b86b4-aeeb-49ba-9b6b-9792dceed6b5", + "name": "phaseACurrent", + "displayName": "Phase A current", + "displayNameEvent": "Phase A current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "fc5df18d-cf2f-4944-97b7-e57dabef8778", + "name": "phaseBCurrent", + "displayName": "Phase B current", + "displayNameEvent": "Phase B current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "17c24cfc-cb41-4873-82b4-19a20d6be146", + "name": "phaseANVoltage", + "displayName": "Phase AN voltage", + "displayNameEvent": "Phase AN volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "f8645ee2-a1e6-4d09-8c20-f6fd02a9e896", + "name": "phaseBNVoltage", + "displayName": "Phase BN voltage", + "displayNameEvent": "Phase BN voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "9235eb4b-906c-4557-8e18-bca268a367cc", + "name": "currentPower", + "displayName": "AC power", + "displayNameEvent": "AC power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "874f5e4a-a009-4c28-b211-2af90a24b2ac", + "name": "frequency", + "displayName": "Line frequency", + "displayNameEvent": "Line frequency changed", + "type": "double", + "unit": "Hertz", + "defaultValue": 0.00 + }, + { + "id": "fe3f8a65-121a-4ae1-b22a-ae325dc3e7e6", + "name": "totalEnergyProduced", + "displayName": "AC energy", + "displayNameEvent": "AC energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "d36bcaba-1e77-431e-89dc-af8ac6172d7d", + "name": "voltageDc", + "displayName": "DC voltage", + "displayNameEvent": "DC volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "68e7239f-d7b2-4ab2-9cf3-e769fb5ddfc0", + "name": "currentDc", + "displayName": "DC current", + "displayNameEvent": "DC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "560b5ec8-1b2c-47d3-b55e-40f4d0aebfe0", + "name": "currentPowerDc", + "displayName": "DC power", + "displayNameEvent": "DC power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "6d314d50-b990-4a58-a37f-4a3da42c4407", + "name": "cabinetTemperature", + "displayName": "Cabinet temperature", + "displayNameEvent": "Cabinet temperature changed", + "type": "double", + "unit": "DegreeCelsius", + "defaultValue": 0.00 + }, + { + "id": "6c1e2929-bc9a-4ce9-a405-6df2633a5131", + "name": "operatingState", + "displayName": "Operating state", + "displayNameEvent": "Operating state changed", + "type": "QString", + "possibleValues": [ + "Off", + "Sleeping", + "Starting", + "MPPT", + "Throttled", + "Shutting down", + "Fault", + "Standby" + ], + "defaultValue": "Off" + }, + { + "id": "5cbfccc9-6afb-404c-a85e-e0323659a25f", + "name": "error", + "displayName": "Error", + "displayNameEvent": "Error changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "2986cac4-f4ac-4299-ac9e-48339e2a54ef", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "name": "sunspecThreePhaseInverter", + "displayName": "SunSpec three phase inverter", + "id": "2e4122ea-96a5-415c-b5e2-7d6012265a83", + "createMethods": [ "Auto" ], + "interfaces": [ "solarinverter", "connectable" ], + "paramTypes": [ + { + "id": "8d5b2b58-ce46-406d-844e-f53136afcf09", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true + }, + { + "id": "e5465ede-9d3d-4558-b614-40dda743ddae", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "640b71ba-2ccd-40d6-bf45-8271a9046f81", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "938b6555-e0af-4222-b85b-90bc1bc78c8f", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "ee169df5-475c-4dfb-870a-0785899b7dc1", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "4401468c-0385-40a9-b436-daf7ed6a50d5", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "26560dd8-6de4-445e-ba55-391d7241c370", + "name": "totalCurrent", + "displayName": "Total AC current", + "displayNameEvent": "Total AC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "3140ccd3-40cf-46c8-8bb2-8c3ea4582f84", + "name": "phaseACurrent", + "displayName": "Phase A current", + "displayNameEvent": "Phase A current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "7ea1a53a-6fd9-4914-8283-b57aa1aaaebf", + "name": "phaseBCurrent", + "displayName": "Phase B current", + "displayNameEvent": "Phase B current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "aa4b4cf5-43d0-4be5-9505-403918b5371d", + "name": "phaseCCurrent", + "displayName": "Phase C current", + "displayNameEvent": "Phase C current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "f08521aa-9c38-4c31-95e1-acb616f6e9c6", + "name": "phaseANVoltage", + "displayName": "Phase AN voltage", + "displayNameEvent": "Phase AN volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "739b8805-d522-4406-bede-d1e4200a3aa9", + "name": "phaseBNVoltage", + "displayName": "Phase BN voltage", + "displayNameEvent": "Phase BN voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "949b797d-5566-4667-8982-e430d23548e2", + "name": "phaseCNVoltage", + "displayName": "Phase CN voltage", + "displayNameEvent": "Phase CN voltage changed", + "type": "int", + "unit": "Volt", + "defaultValue": 0, + "filter": "adaptive" + }, + { + "id": "14036f44-25fd-4e93-8e8c-c677b06a2c34", + "name": "currentPower", + "displayName": "AC power", + "displayNameEvent": "AC power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "faa45cae-ed28-4150-9036-fceddf9d6776", + "name": "frequency", + "displayName": "Frequency", + "displayNameEvent": "Frequency changed", + "type": "double", + "unit": "Hertz", + "defaultValue": 0.00 + }, + { + "id": "d493880d-eb58-4530-8010-8ea4f6d63387", + "name": "totalEnergyProduced", + "displayName": "AC energy", + "displayNameEvent": "AC energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "8c1c71c4-28fd-472a-83a6-3e96ab03d677", + "name": "voltageDc", + "displayName": "DC voltage", + "displayNameEvent": "DC volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "81a989aa-4104-438e-bfc5-4f44ce5ceeaf", + "name": "currentDc", + "displayName": "DC current", + "displayNameEvent": "DC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "1397d935-1206-4ceb-883d-1fcf41ba6214", + "name": "currentPowerDc", + "displayName": "DC power", + "displayNameEvent": "DC power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "44b0320f-89a7-4248-bad4-288ef898a5cc", + "name": "cabinetTemperature", + "displayName": "Cabinet temperature", + "displayNameEvent": "Cabinet temperature changed", + "type": "double", + "unit": "DegreeCelsius", + "defaultValue": 0.00 + }, + { + "id": "cebdce98-42d1-4a28-8834-8960efc0e83f", + "name": "operatingState", + "displayName": "Operating state", + "displayNameEvent": "Operating state changed", + "type": "QString", + "possibleValues": [ + "Off", + "Sleeping", + "Starting", + "MPPT", + "Throttled", + "Shutting down", + "Fault", + "Standby" + ], + "defaultValue": "Off" + }, + { + "id": "4479af96-c499-4f15-abd6-4afdb18a9e09", + "name": "error", + "displayName": "Error", + "displayNameEvent": "Error changed", + "type": "QString", + "defaultValue": "" + }, + { + "id": "3b543b60-d8f6-4bc0-a79f-cb9cb20e4bb8", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "name": "sunspecSinglePhaseMeter", + "displayName": "SunSpec single phase meter", + "id": "7ffa43b8-b56f-4435-8509-980e9d81dfa8", + "createMethods": [ "Auto" ], + "interfaces": [ "energymeter", "connectable" ], + "paramTypes": [ + { + "id": "7d6fcafb-c62e-4a21-aae2-f4041c487149", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "30b90ec0-429b-4e6c-88e9-155aa4bcad47", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "811de380-848a-448c-8446-daa0e0fd8e50", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "e31333c8-746d-4955-af0a-ae18d7100de7", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "581fa623-0005-448f-a5b1-8db1043795a3", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "d960e7b1-d4aa-4cab-8f54-6bcfdbb8be36", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "93cf8c6a-2620-42ed-9070-e0726d7b1dbc", + "name": "currentPower", + "displayName": "Total real power", + "displayNameEvent": "Total real power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "ba275bdf-f418-4ef0-afbe-ac425c6f6783", + "name": "totalEnergyProduced", + "displayName": "Total real energy exported", + "displayNameEvent": "Total real energy exported changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "c51dc6cb-5c05-4078-b11a-26afb2f85541", + "name": "totalEnergyConsumed", + "displayName": "Total real energy imported", + "displayNameEvent": "Total real energy imported changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "4a058e36-0b45-4388-9a26-0615f7aafa0d", + "name": "currentPhaseA", + "displayName": "Phase A current", + "displayNameEvent": "Phase A current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "5e7a65a5-b740-4395-87fa-ffbe8fe1c823", + "name": "voltagePhaseA", + "displayName": "Phase A voltage", + "displayNameEvent": "Phase A voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "3a2ce51d-7fa0-4188-bbd6-00d25de90e15", + "name": "frequency", + "displayName": "Frequency", + "displayNameEvent": "Frequency changed", + "type": "double", + "unit": "Hertz", + "defaultValue": 0.00 + }, + { + "id": "94adff6f-f672-4e4e-a72c-cd6cd7e8721a", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "name": "sunspecSplitPhaseMeter", + "displayName": "SunSpec split phase meter", + "id": "b8a18e45-5ff5-4f43-915f-04ee216c809d", + "createMethods": [ "Auto" ], + "interfaces": [ "energymeter", "connectable" ], + "paramTypes": [ + { + "id": "89aeec6d-abeb-48b5-9594-214ad5db2d03", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "a56f198d-ed86-429f-b839-8e11a32da8c1", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "ecbdee01-8be7-42bf-9e1a-a0f700669b09", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "6bc978cb-a779-4f5f-87a4-3cbbe2a55ca8", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "fb7cc04b-91cb-4f3d-a444-201cca57934a", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "34e34ec9-dab0-438c-9493-a3068bc401de", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "8a63bd73-0546-4636-8da2-23238cc06fb2", + "name": "totalEnergyProduced", + "displayName": "Total real energy exported", + "displayNameEvent": "Total real energy exported changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "51ffb2ae-3920-40df-8290-bbf5b6e1a68f", + "name": "totalEnergyConsumed", + "displayName": "Total real energy imported", + "displayNameEvent": "Total real energy imported changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "ef4bc0f8-f516-49b7-aba8-d5f987485aca", + "name": "currentPower", + "displayName": "Total real power", + "displayNameEvent": "Total real power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "481b9895-1c3e-4e74-9418-ee51dae80a63", + "name": "energyConsumedPhaseA", + "displayName": "Energy consumed phase A", + "displayNameEvent": "Energy consumed phase A changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "10245c6c-4b0d-44b4-be10-72026b842c86", + "name": "energyConsumedPhaseB", + "displayName": "Energy consumed phase B", + "displayNameEvent": "Energy consumed phase B changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "2dc76576-561c-4bce-8799-fadc55e84aee", + "name": "energyProducedPhaseA", + "displayName": "Energy produced phase A", + "displayNameEvent": "Energy produced phase A changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "b1489efd-ece0-419e-ab1c-d3d25ab6b958", + "name": "energyProducedPhaseB", + "displayName": "Energy produced phase B", + "displayNameEvent": "Energy produced phase B changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "e85024af-5376-4ff1-813e-5a56990c11cc", + "name": "totalCurrent", + "displayName": "Total AC current", + "displayNameEvent": "Total AC current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "57c0f5ba-a104-424b-b8d4-8bab90acf009", + "name": "currentPowerPhaseA", + "displayName": "Current power phase A", + "displayNameEvent": "Current power phase A changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "456f7cdf-9756-49b8-9084-f212199d4014", + "name": "currentPowerPhaseB", + "displayName": "Current power phase B", + "displayNameEvent": "Current power phase B changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "e8c0f4bf-a704-46f2-80a0-cf490bd7871b", + "name": "currentPhaseA", + "displayName": "Phase A current", + "displayNameEvent": "Phase A current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "4281f6fc-d5a0-4a22-ac61-6bec88efbc80", + "name": "currentPhaseB", + "displayName": "Phase B current", + "displayNameEvent": "Phase B current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "0ac79508-07c3-4d01-97a3-6edf121bdf32", + "name": "lnACVoltage", + "displayName": "Line to Neutral AC Voltage", + "displayNameEvent": "Line to Neutral AC Voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "1bd7e53e-abf8-4d62-b87c-2c84c283567b", + "name": "voltagePhaseA", + "displayName": "Phase A voltage", + "displayNameEvent": "Phase A volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "377b5279-ddb6-451d-8377-a9389c749393", + "name": "voltagePhaseB", + "displayName": "Phase B voltage", + "displayNameEvent": "Phase B voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "db977c04-a3e1-436f-a0cd-9ce5b7bc6b89", + "name": "frequency", + "displayName": "Frequency", + "displayNameEvent": "Frequency changed", + "type": "double", + "unit": "Hertz", + "defaultValue": 0.00 + }, + { + "id": "85c5087c-77b4-4ac8-bdff-020f6242bd57", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "name": "sunspecThreePhaseMeter", + "displayName": "SunSpec three phase meter", + "id": "68f822f9-ff30-4275-b229-39a3674fead7", + "createMethods": [ "Auto" ], + "interfaces": [ "energymeter", "connectable" ], + "paramTypes": [ + { + "id": "a1960821-155c-4176-86fa-974429039182", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "6d5dbd35-1bf6-46db-bee9-90c679421b89", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "462552de-af84-4893-9132-81c1adce67ad", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "00d308dc-e9a3-40e4-acad-3cad2ca3590f", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "97bbcc98-f4ec-4060-bac2-31b40720862c", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "36f861c7-afc1-4725-b41f-67113200d78f", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "01a69b2c-cc96-4925-9b1c-a2f204ef0837", + "name": "totalEnergyProduced", + "displayName": "Total real energy exported", + "displayNameEvent": "Total real energy exported changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "0a98b229-7625-4811-8bf8-ed22be2db3fb", + "name": "totalEnergyConsumed", + "displayName": "Total real energy imported", + "displayNameEvent": "Total real energy imported changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "47780b0a-68b7-46c3-bb98-79ec9a57fd69", + "name": "currentPower", + "displayName": "Total real power", + "displayNameEvent": "Total real power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "cc0fd197-22ed-4461-8bba-47460396798a", + "name": "energyConsumedPhaseA", + "displayName": "Energy consumed phase A", + "displayNameEvent": "Energy consumed phase A changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "e928fde3-b35b-4884-9810-1cd083725e1c", + "name": "energyConsumedPhaseB", + "displayName": "Energy consumed phase B", + "displayNameEvent": "Energy consumed phase B changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "2a12a991-d6c8-4665-bcc8-ea2685f7f0d4", + "name": "energyConsumedPhaseC", + "displayName": "Energy consumed phase C", + "displayNameEvent": "Energy consumed phase C changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "fe7caa7e-cf37-4550-833e-7f9d35001fdd", + "name": "energyProducedPhaseA", + "displayName": "Energy produced phase A", + "displayNameEvent": "Energy produced phase A changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "edbe64a5-121e-4c96-9dc8-bbc2a33dfdb7", + "name": "energyProducedPhaseB", + "displayName": "Energy produced phase B", + "displayNameEvent": "Energy produced phase B changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "a26790c0-7378-471c-80ba-67021352415d", + "name": "energyProducedPhaseC", + "displayName": "Energy produced phase C", + "displayNameEvent": "Energy produced phase C changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "0e3e8870-3d59-4ee8-b7aa-33bda68b6ae3", + "name": "currentPowerPhaseA", + "displayName": "Current power phase A", + "displayNameEvent": "Current power phase A changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "453d046c-3a66-4590-882c-1283882918fc", + "name": "currentPowerPhaseB", + "displayName": "Current power phase B", + "displayNameEvent": "Current power phase B changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "ba4e94de-9136-48a7-aad8-3d5cd0e7d968", + "name": "currentPowerPhaseC", + "displayName": "Current power phase C", + "displayNameEvent": "Current power phase C changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "46ae5c40-f389-4f7e-a59f-8947d7dc9f3f", + "name": "currentPhaseA", + "displayName": "Phase A current", + "displayNameEvent": "Phase A current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "efc75436-ac51-4fee-81f1-65a726416980", + "name": "currentPhaseB", + "displayName": "Phase B current", + "displayNameEvent": "Phase B current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "618cbcc5-2efe-49d2-9c5c-53fb3eaa3a9d", + "name": "currentPhaseC", + "displayName": "Phase C current", + "displayNameEvent": "Phase C current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "4ff342de-f0b7-475a-99e4-3a926326d1b7", + "name": "voltagePhaseA", + "displayName": "Phase A voltage", + "displayNameEvent": "Phase A volatage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "cc8434f1-02fe-41ad-bc8a-94e57293f8fc", + "name": "voltagePhaseB", + "displayName": "Phase B voltage", + "displayNameEvent": "Phase B voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "fbd673cf-8891-4aef-b112-2829f5fcfe06", + "name": "voltagePhaseC", + "displayName": "Phase C voltage", + "displayNameEvent": "Phase C voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00, + "filter": "adaptive" + }, + { + "id": "66734fc7-b3e4-48d4-8cb7-f0a9f7cef88a", + "name": "frequency", + "displayName": "Frequency", + "displayNameEvent": "Frequency changed", + "type": "double", + "unit": "Hertz", + "defaultValue": 0.00 + }, + { + "id": "3d908dd2-4a93-4b62-8cbb-2fa9ef4d28fa", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "name": "sunspecStorage", + "displayName": "SunSpec Storage", + "id": "9a643ba8-346c-4127-a2f8-956a7133d75e", + "createMethods": [ "Auto" ], + "interfaces": ["battery", "connectable"], + "paramTypes": [ + { + "id": "219beb96-b9fe-4dd2-a386-ecfbbab8786d", + "name":"modelId", + "displayName": "Model", + "type": "int", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "3f107844-00c5-4f39-86e5-485b3d1f5c1a", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "7ee31b97-caec-4cdb-80c9-71e232c5fb22", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "2abf786d-18be-46cb-b4a3-7e0934a4c3fd", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "5cf43b80-3e65-4f69-8bf5-91810da0ead4", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "Unkown" + } + ], + "stateTypes":[ + { + "id": "25a1fb10-a6b9-4037-b7cf-ad481a65beb4", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "3171a6e0-43a7-4de8-8e20-f748e44af7ac", + "name": "batteryCritical", + "displayName": "Battery critical", + "displayNameEvent": "Battery critical changed", + "type": "bool", + "defaultValue": false + }, + { + "id": "0bf53f80-97f8-488b-b514-58f9fe08c183", + "name": "batteryLevel", + "displayName": "Battery level", + "displayNameEvent": "Battery level changed", + "type": "int", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0 + }, + { + "id": "da2b19c5-0f48-49d1-93f0-abdc0051407d", + "name": "storageStatus", + "displayName": "Status", + "displayNameEvent": "Status changed", + "type": "QString", + "possibleValues": [ + "Off", + "Empty", + "Discharging", + "Charging", + "Full", + "Holding", + "Testing" + ], + "defaultValue": "Off" + }, + { + "id": "f6178427-8e04-45c7-abec-27d04dd5ff04", + "name": "chargingState", + "displayName": "Charging state", + "displayNameEvent": "Charging state changed", + "type": "QString", + "possibleValues": ["idle", "charging", "discharging"], + "defaultValue": "idle" + }, + { + "id": "221a2ef6-0a92-4ff0-87fe-7bd920dbec0b", + "name": "gridCharging", + "displayName": "Grid charging", + "displayNameEvent": "Grid charging changed", + "type": "bool", + "defaultValue": false, + "writable": true, + "displayNameAction": "Set grid charging" + }, + { + "id": "1f530f79-c0d2-466b-90e1-79149e34d92f", + "name": "enableCharging", + "displayName": "Charging", + "displayNameEvent": "Charging changed", + "displayNameAction": "Enable charging", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "id": "bc99a159-815a-40ab-a6e8-b46f315305f7", + "name": "enableDischarging", + "displayName": "Discharging", + "displayNameEvent": "Discharging changed", + "displayNameAction": "Enable discharging", + "type": "bool", + "defaultValue": false, + "writable": true + }, + { + "id": "7f469bbc-64a5-4045-8d5f-9a1a85039851", + "name": "chargingRate", + "displayName": "Charging rate", + "displayNameEvent": "Charging rate changed", + "type": "int", + "minValue": 0, + "maxValue": 100, + "unit": "Percentage", + "defaultValue": false, + "writable": true, + "displayNameAction": "Set charging rate" + }, + { + "id": "6068f030-acce-44a2-b95f-bd00dd5ca760", + "name": "dischargingRate", + "displayName": "Discharging rate", + "displayNameEvent": "Discharging rate changed", + "type": "int", + "minValue": 0, + "maxValue": 100, + "unit": "Percentage", + "defaultValue": false, + "writable": true, + "displayNameAction": "Set discharging rate" + }, + { + "id": "fca0c151-d8fa-4d6e-a5d7-b3bc965abc09", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + } + ] + }, + { + "name": "solarEdge", + "displayName": "SolarEdge", + "id": "addd7dd8-13e1-405b-ba61-b2d82eaf0426", + "thingClasses": [ + { + "name": "solarEdgeConnection", + "displayName": "SolarEdge", + "id": "7a92bf65-b443-4491-a012-2bec35eb5bf0", + "createMethods": [ "discovery", "user" ], + "interfaces": [ "gateway", "networkdevice" ], + "providedInterfaces": [ "solarinverter", "energymeter", "energystorage" ], + "discoveryParamTypes": [ + { + "id": "9025a4e9-d994-45c7-9a00-62f7abf47107", + "name":"slaveId", + "displayName": "Slave ID", + "type": "int", + "defaultValue": 1 + } + ], + "paramTypes": [ + { + "id": "9c2bafd0-6d56-42e0-8ef3-c4940b4f18b5", + "name": "address", + "displayName": "Host address", + "type": "QString", + "inputType": "IPv4Address", + "defaultValue": "" + }, + { + "id": "2e22c369-8476-4908-9cdc-186472f4d472", + "name": "hostName", + "displayName": "Host name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "bb395c12-54d6-4139-b0a6-e31b28bc4d2e", + "name":"macAddress", + "displayName": "MAC address", + "type": "QString", + "inputType": "MacAddress", + "defaultValue": "" + }, + { + "id": "1bcede48-d167-4ced-8f1b-bea6302dd43f", + "name":"port", + "displayName": "Port", + "type": "int", + "defaultValue": 502 + }, + { + "id": "b9cc58cf-ad15-447a-a94d-17b9204313bc", + "name":"slaveId", + "displayName": "Slave ID", + "type": "int", + "defaultValue": 1 + } + ], + "stateTypes":[ + { + "id": "f51d4e5a-24ea-41f9-a453-d2bdd68d804c", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + } + ] + }, + { + "name": "solarEdgeBattery", + "displayName": "SolarEdge battery", + "id": "7900471a-da3f-4f03-98be-3e3d9c841546", + "createMethods": [ "Auto" ], + "interfaces": ["energystorage", "connectable"], + "paramTypes": [ + { + "id": "7413cce6-48c1-4924-89bc-915beed67b57", + "name":"modbusAddress", + "displayName": "Modbus address", + "type": "uint", + "readOnly": true, + "defaultValue": 0 + }, + { + "id": "8a4b19a9-ee14-42aa-9146-fa84e15970ee", + "name":"manufacturer", + "displayName": "Manufacturer", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "e90a3833-1eb6-43ed-a72a-0cf7cb8015ca", + "name":"deviceModel", + "displayName": "Device model", + "type": "QString", + "defaultValue": "Unkown" + }, + { + "id": "efd99fe4-3a48-4208-829d-14e062015ee7", + "name":"serialNumber", + "displayName": "Serial number", + "type": "QString", + "defaultValue": "" + } + ], + "stateTypes":[ + { + "id": "8addf8aa-f879-4afa-935d-4d2ffa38d05e", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + }, + { + "id": "897616db-e815-4cda-b890-13b37a94ad17", + "name": "batteryCritical", + "displayName": "Battery critical", + "displayNameEvent": "Battery critical changed", + "type": "bool", + "defaultValue": false + }, + { + "id": "a4b0c8c9-44f0-43e2-ab25-63fdc9772fb8", + "name": "batteryLevel", + "displayName": "Battery level", + "displayNameEvent": "Battery level changed", + "type": "int", + "unit": "Percentage", + "minValue": 0, + "maxValue": 100, + "defaultValue": 0 + }, + { + "id": "815b0826-c392-4249-a271-19e92ebe46e0", + "name": "chargingState", + "displayName": "Charging state", + "displayNameEvent": "Charging state changed", + "type": "QString", + "possibleValues": ["idle", "charging", "discharging"], + "defaultValue": "idle" + }, + { + "id": "f6c6f548-8a12-4a62-a641-08bb003c55fa", + "name": "ratedEnergy", + "displayName": "Rated energy", + "displayNameEvent": "Rated energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "f46298e5-d06c-4ebe-869a-d9feff1a4689", + "name": "averageTemperature", + "displayName": "Average temperature", + "displayNameEvent": "Average temperature changed", + "type": "double", + "unit": "DegreeCelsius", + "defaultValue": 0.00 + }, + { + "id": "9d17defb-fc6e-4c10-af53-14b796222a14", + "name": "instantaneousVoltage", + "displayName": "Instantaneous voltage", + "displayNameEvent": "Instantaneous voltage changed", + "type": "double", + "unit": "Volt", + "defaultValue": 0.00 + }, + { + "id": "89f58155-ca0c-48a8-aa7f-6e4d1b1bad36", + "name": "instantaneousCurrent", + "displayName": "Instantaneous current", + "displayNameEvent": "Instantaneous current changed", + "type": "double", + "unit": "Ampere", + "defaultValue": 0.00 + }, + { + "id": "caccf9fd-0f83-4dd5-ae48-a497f13d2a2e", + "name": "currentPower", + "displayName": "Instantaneous power", + "displayNameEvent": "Instantaneous power changed", + "type": "double", + "unit": "Watt", + "defaultValue": 0.00 + }, + { + "id": "d8cc799d-9709-4342-8766-52ff5f12aecc", + "name": "maxEnergy", + "displayName": "Maximum energy", + "displayNameEvent": "Maximum energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "7ac7ccd1-b5d1-488b-a99b-98ef000b7d03", + "name": "capacity", + "displayName": "Available energy", + "displayNameEvent": "Available energy changed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0.00 + }, + { + "id": "24b501db-f301-422d-ad58-f16ca025f6ee", + "name": "stateOfHealth", + "displayName": "State of health (SOH)", + "displayNameEvent": "State of health (SOH) changed", + "type": "double", + "unit": "Percentage", + "defaultValue": 0.00 + }, + { + "id": "9c24b27a-3ea1-4db1-8868-6c999aad0a18", + "name": "batteryStatus", + "displayName": "Battery status", + "displayNameEvent": "Battery status changed", + "type": "QString", + "possibleValues": [ + "Off", + "Standby", + "Init", + "Charging", + "Discharging", + "Fault", + "Holding", + "Idle" + ], + "defaultValue": "Idle" + }, + { + "id": "db2e0304-4f0e-461f-8bab-c63fcb5deb1b", + "name": "version", + "displayName": "Version", + "displayNameEvent": "Version changed", + "type": "QString", + "defaultValue": "" + } + ] + } + ] + } + ] +} diff --git a/sunspec/meta.json b/sunspec/meta.json new file mode 100644 index 0000000..8d08966 --- /dev/null +++ b/sunspec/meta.json @@ -0,0 +1,14 @@ +{ + "title": "SunSpec", + "tagline": "Connect to SunSpec devices.", + "icon": "sunspec.png", + "stability": "consumer", + "offline": true, + "technologies": [ + "network", + "modbus" + ], + "categories": [ + "energy" + ] +} diff --git a/sunspec/solaredgebattery.cpp b/sunspec/solaredgebattery.cpp new file mode 100644 index 0000000..22e8373 --- /dev/null +++ b/sunspec/solaredgebattery.cpp @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "solaredgebattery.h" +#include "extern-plugininfo.h" + +#include +#include + +SolarEdgeBattery::SolarEdgeBattery(Thing *thing, SunSpecConnection *connection, int modbusStartRegister, QObject *parent) : + SunSpecThing(thing, nullptr, parent), + m_connection(connection), + m_modbusStartRegister(modbusStartRegister) +{ + m_timer.setSingleShot(true); + m_timer.setInterval(10000); + connect(&m_timer, &QTimer::timeout, this, [this](){ + if (!m_initFinishedSuccess) { + emit initFinished(false); + } + }); +} + +SunSpecConnection *SolarEdgeBattery::connection() const +{ + return m_connection; +} + +quint16 SolarEdgeBattery::modbusStartRegister() const +{ + return m_modbusStartRegister; +} + +SolarEdgeBattery::BatteryData SolarEdgeBattery::batteryData() const +{ + return m_batteryData; +} + +void SolarEdgeBattery::init() +{ + qCDebug(dcSunSpec()) << "Initializing battery on" << m_modbusStartRegister; + m_initFinishedSuccess = false; + readBlockData(); + m_timer.start(); +} + +void SolarEdgeBattery::readBlockData() +{ + // Read the data in 2 block requests + qCDebug(dcSunSpec()) << "SolarEdgeBattery: Read block 1 from modbus address" << m_modbusStartRegister << "length" << 107<< ", Slave ID" << m_connection->slaveId(); + + // Total possible block size is 0xE19A - 0xE100 = 0x9A = 153 registers + + // First block request 0x00 - 0x4C + QModbusDataUnit request = QModbusDataUnit(QModbusDataUnit::RegisterType::HoldingRegisters, m_modbusStartRegister, 0x4C); + if (QModbusReply *reply = m_connection->modbusTcpClient()->sendReadRequest(request, m_connection->slaveId())) { + if (!reply->isFinished()) { + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, this, [=]() { + if (reply->error() != QModbusDevice::NoError) { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read response error:" << reply->error(); + if (!m_initFinishedSuccess) { + m_timer.stop(); + emit initFinished(false); + } + return; + } + + // Example data: + // "(0x3438, 0x565f, 0x4c47, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x4c47, 0x4320, 0x5245, 0x5355, 0x2031, 0x3000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x00ff, 0x0000, 0xffff, 0xff7f, 0xffff, 0xff7f, 0xffff, 0xff7f, 0xffff, 0xff7f, 0xffff, 0xff7f)" + // 255 "48V_LG" "LGC RESU 10" "" "" + // "(0x3438, 0x565f, 0x4c47, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x4c47, 0x4320, 0x5245, 0x5355, 0x2031, 0x3000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x3438, 0x5620, 0x4443, 0x4443, 0x2032, 0x2e32, 0x2e39, 0x3120, 0x424d, 0x5320, 0x302e, 0x302e, 0x3000, 0x0000, 0x0000, 0x0000, 0x3745, 0x3034, 0x3432, 0x4543, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0070, 0x0000, 0x2000, 0x4619, 0x4000, 0x459c, 0x4000, 0x459c, 0x4000, 0x44ce, 0x4000, 0x459c)" + // 112 "48V_LG" "LGC RESU 10" "48V DCDC 2.2.91 BMS 0.0.0" "7E0442EC" + + + const QModbusDataUnit unit = reply->result(); + QVector values = unit.values(); + qCDebug(dcSunSpec()) << "SolarEdgeBattery: Received first block data" << m_modbusStartRegister << values.length(); + qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << SunSpecDataPoint::registersToString(values); + + m_batteryData.manufacturerName = SunSpecDataPoint::convertToString(values.mid(ManufacturerName, 16)); + m_batteryData.model = SunSpecDataPoint::convertToString(values.mid(Model, 16)); + m_batteryData.firmwareVersion = SunSpecDataPoint::convertToString(values.mid(FirmwareVersion, 16)); + m_batteryData.serialNumber = SunSpecDataPoint::convertToString(values.mid(SerialNumber, 16)); + m_batteryData.batteryDeviceId = values[BatteryDeviceId]; + qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << m_batteryData.batteryDeviceId << m_batteryData.manufacturerName << m_batteryData.model << m_batteryData.firmwareVersion << m_batteryData.serialNumber; + + // Check if there is a battery connected, if so, one of the string must contain vaild data... + if (m_batteryData.manufacturerName.isEmpty() && m_batteryData.model.isEmpty() && m_batteryData.serialNumber.isEmpty() && m_batteryData.firmwareVersion.isEmpty()) { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: No valid information detected about the battery. Probably no battery connected at register" << m_modbusStartRegister; + if (!m_initFinishedSuccess) { + m_timer.stop(); + emit initFinished(false); + } + return; + } + + // For some reason, there might be even data in there but no battery connected, let's check if there are invalid registers + + // Check if there is a battery connected, if so, one of the string must contain vaild data... + const QVector invalidRegisters = { 0xffff, 0xff7f }; + if (values.mid(RatedEnergy, 2) == invalidRegisters && values.mid(MaxChargeContinuesPower, 2) == invalidRegisters && + values.mid(MaxDischargeContinuesPower, 2) == invalidRegisters && values.mid(MaxChargePeakPower, 2) == invalidRegisters && + values.mid(MaxDischargePeakPower, 2) == invalidRegisters) { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: No valid information detected about the battery. Probably no battery connected at register" << m_modbusStartRegister; + if (!m_initFinishedSuccess) { + m_timer.stop(); + emit initFinished(false); + } + return; + } + + m_batteryData.ratedEnergy = SunSpecDataPoint::convertToFloat32(values.mid(RatedEnergy, 2)); + m_batteryData.maxChargeContinuesPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxChargeContinuesPower, 2)); + m_batteryData.maxDischargeContinuesPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxDischargeContinuesPower, 2)); + m_batteryData.maxChargePeakPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxChargePeakPower, 2)); + m_batteryData.maxDischargePeakPower = SunSpecDataPoint::convertToFloat32(values.mid(MaxDischargePeakPower, 2)); + + // First block looks good, continue with second block + + // 8192 17945 536888857 536888857 1.08652e-19 + // 0x2000 0x4619 + + // Read from 0x6c to 0x86 + int offset = 0x6c; + QModbusDataUnit request = QModbusDataUnit(QModbusDataUnit::RegisterType::HoldingRegisters, m_modbusStartRegister + offset, 28); + if (QModbusReply *reply = m_connection->modbusTcpClient()->sendReadRequest(request, m_connection->slaveId())) { + if (!reply->isFinished()) { + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, this, [=]() { + if (reply->error() != QModbusDevice::NoError) { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read response error:" << reply->error(); + if (!m_initFinishedSuccess) { + m_timer.stop(); + emit initFinished(false); + } + return; + } + + const QModbusDataUnit unit = reply->result(); + QVector values = unit.values(); + + qCDebug(dcSunSpec()) << "SolarEdgeBattery: Received second block data" << m_modbusStartRegister + offset << values.length(); + qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << SunSpecDataPoint::registersToString(values); + QVector valueRegisters; + + valueRegisters = values.mid(BatteryAverageTemperature - offset, 2); + m_batteryData.averageTemperature = SunSpecDataPoint::convertToFloat32(valueRegisters); + qCDebug(dcSunSpec()) << "SolarEdgeBattery: Average temperature:" << SunSpecDataPoint::registersToString(valueRegisters) << m_batteryData.averageTemperature; + + m_batteryData.maxTemperature = SunSpecDataPoint::convertToFloat32(values.mid(BatteryMaxTemperature - offset, 2)); + m_batteryData.instantaneousVoltage = SunSpecDataPoint::convertToFloat32(values.mid(InstantaneousVoltage - offset, 2)); + m_batteryData.instantaneousCurrent = SunSpecDataPoint::convertToFloat32(values.mid(InstantaneousCurrent - offset, 2)); + m_batteryData.instantaneousPower = SunSpecDataPoint::convertToFloat32(values.mid(InstantaneousPower - offset, 2)); + m_batteryData.maxEnergy = SunSpecDataPoint::convertToFloat32(values.mid(MaxEnergy - offset, 2)); + + valueRegisters = values.mid(AvailableEnergy - offset, 2); + m_batteryData.availableEnergy = SunSpecDataPoint::convertToFloat32(valueRegisters); + qCDebug(dcSunSpec()) << "SolarEdgeBattery: Available energy:" << (AvailableEnergy - offset) << SunSpecDataPoint::registersToString(valueRegisters) << m_batteryData.availableEnergy; + + m_batteryData.stateOfHealth = SunSpecDataPoint::convertToFloat32(values.mid(StateOfHealth - offset, 2)); + m_batteryData.stateOfEnergy = SunSpecDataPoint::convertToFloat32(values.mid(StateOfEnergy - offset, 2)); + m_batteryData.batteryStatus = static_cast(SunSpecDataPoint::convertToUInt32(values.mid(Status - offset, 2))); + + if (!m_initFinishedSuccess) { + m_timer.stop(); + m_initFinishedSuccess = true; + emit initFinished(true); + } + + emit blockDataUpdated(); + }); + + connect(reply, &QModbusReply::errorOccurred, this, [] (QModbusDevice::Error error) { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Modbus reply error:" << error; + }); + } else { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString(); + reply->deleteLater(); // broadcast replies return immediately + if (!m_initFinishedSuccess) { + m_timer.stop(); + emit initFinished(false); + } + return; + } + } else { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString(); + return; + } + }); + + connect(reply, &QModbusReply::errorOccurred, this, [] (QModbusDevice::Error error) { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Modbus reply error:" << error; + }); + } else { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString(); + reply->deleteLater(); // broadcast replies return immediately + if (!m_initFinishedSuccess) { + m_timer.stop(); + emit initFinished(false); + } + return; + } + } else { + qCWarning(dcSunSpec()) << "SolarEdgeBattery: Read error: " << m_connection->modbusTcpClient()->errorString(); + return; + } +} + +QDebug operator<<(QDebug debug, const SolarEdgeBattery::BatteryData &batteryData) +{ + debug << "SolarEdgeBatteryData(" << batteryData.manufacturerName << "-" << batteryData.model << ")" << "\n"; + debug << " - Battery Device ID" << batteryData.batteryDeviceId << "\n"; + debug << " - Firmware version" << batteryData.firmwareVersion << "\n"; + debug << " - Serial number" << batteryData.serialNumber << "\n"; + debug << " - Rated Energy" << batteryData.ratedEnergy << "W * H" << "\n"; + debug << " - Max charging continues power" << batteryData.maxChargeContinuesPower << "W" << "\n"; + debug << " - Max discharging continues power" << batteryData.maxDischargeContinuesPower << "W" << "\n"; + debug << " - Max charging peak power" << batteryData.maxChargePeakPower << "W" << "\n"; + debug << " - Max discharging peak power" << batteryData.maxDischargePeakPower << "W" << "\n"; + debug << " - Average temperature" << batteryData.averageTemperature << "°C" << "\n"; + debug << " - Max temperature" << batteryData.maxTemperature << "°C" << "\n"; + debug << " - Instantuouse Voltage" << batteryData.instantaneousVoltage << "V" << "\n"; + debug << " - Instantuouse Current" << batteryData.instantaneousCurrent << "A" << "\n"; + debug << " - Instantuouse Power" << batteryData.instantaneousPower << "W" << "\n"; + debug << " - Max energy" << batteryData.maxEnergy << "W * H" << "\n"; + debug << " - Available energy" << batteryData.availableEnergy << "W * H" << "\n"; + debug << " - State of health" << batteryData.stateOfHealth << "%" << "\n"; + debug << " - State of energy" << batteryData.stateOfEnergy << "%" << "\n"; + debug << " - Battery status" << batteryData.batteryStatus << "\n"; + return debug; +} diff --git a/sunspec/solaredgebattery.h b/sunspec/solaredgebattery.h new file mode 100644 index 0000000..824f7cf --- /dev/null +++ b/sunspec/solaredgebattery.h @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef SOLAREDGEBATTERY_H +#define SOLAREDGEBATTERY_H + +#include +#include +#include + +#include "sunspecthing.h" + +/* Note: this is actually not a sunspec thing, since SolarEdge provide battery information using a non SunSpec modbus register section. + * For keeping the code as simple as possible we make it a SunSpec thing */ + +class SolarEdgeBattery : public SunSpecThing +{ + Q_OBJECT +public: + enum BatteryRegister { + ManufacturerName = 0x00, + Model = 0x10, + FirmwareVersion = 0x20, + SerialNumber = 0x30, + BatteryDeviceId = 0x40, + RatedEnergy = 0x42, // W * H + MaxChargeContinuesPower = 0x44, // W + MaxDischargeContinuesPower = 0x46, // W + MaxChargePeakPower = 0x48, // W + MaxDischargePeakPower = 0x4A, // W + // 32 byte reserved + BatteryAverageTemperature = 0x6c, // °C + BatteryMaxTemperature = 0x6E, + InstantaneousVoltage = 0x70, // V + InstantaneousCurrent = 0x72, // A + InstantaneousPower = 0x74, // W + LifetimeExportEnergyCounter = 0x76, // W * H + LifetimeImportEnergyCounter = 0x7A, // W * H + MaxEnergy = 0x7E, // W * H + AvailableEnergy = 0x80, // W * H + StateOfHealth = 0x82, + StateOfEnergy = 0x84, + Status = 0x86 + }; + Q_ENUM(BatteryRegister) + + enum BatteryStatus { + Off = 0, + Standby = 1, + Init = 2, + Charge = 3, + Discharge = 4, + Fault = 5, + Holding = 6, + Idle = 7 + }; + Q_ENUM(BatteryStatus) + + struct BatteryData { + QString manufacturerName; + QString model; + QString firmwareVersion; + QString serialNumber; + quint16 batteryDeviceId; + float ratedEnergy; + float maxChargeContinuesPower; + float maxDischargeContinuesPower; + float maxChargePeakPower; + float maxDischargePeakPower; + float averageTemperature; + float maxTemperature; + float instantaneousVoltage; + float instantaneousCurrent; + float instantaneousPower; + quint64 lifetimeExportEnergyCounter; + quint64 lifetimeImportEnergyCounter; + float maxEnergy; + float availableEnergy; + float stateOfHealth; + float stateOfEnergy; + BatteryStatus batteryStatus; + }; + + explicit SolarEdgeBattery(Thing *thing, SunSpecConnection *connection, int modbusStartRegister, QObject *parent = nullptr); + + SunSpecConnection *connection() const override; + + quint16 modbusStartRegister() const override; + + BatteryData batteryData() const; + + void init(); + void readBlockData() override; + +signals: + void initFinished(bool success); + void blockDataUpdated(); + +private: + QTimer m_timer; + SunSpecConnection *m_connection = nullptr; + int m_modbusStartRegister; + bool m_initFinishedSuccess = false; + BatteryData m_batteryData; + +}; + +QDebug operator<<(QDebug debug, const SolarEdgeBattery::BatteryData &batteryData); + +#endif // SOLAREDGEBATTERY_H diff --git a/sunspec/sunspec.png b/sunspec/sunspec.png new file mode 100644 index 0000000000000000000000000000000000000000..7a52a4529d365fccd4b06978df239f8736c553e5 GIT binary patch literal 8836 zcma)iWl)@5vn?SL+}(rAV8I;%41?R?P6l^(3naL^LvVKso&+ZWg3I7AxC97phqunD z`hMJd-g|#M&#qlv+q-+M>ROR%DzcbpWM~Ko2$=G6AjnHQ{LhDi^zyIKt<6I~phl1f zNosm89p@q&5)8pU)(O;pQ!n}Skyky6uz~f8tTU1aTI-s2!3NdBLlf49Dj`EV>-m+y8@)t;(xwwlH%g!Bf$H*-Rr5^4jjzua12AEMRN9@rk#vL(E$T^k)$6aLI z%7v?IXx}YxGOMg&SByKHLRS2p64@y)oB@SMSoT{X^VaJm0=6tPz_REfPRtaL$&=(0 zus|dW^gLYH7?s{gyG1+SSe;~T?_*MT;Z?SGe3wg;xpclR_lbIq0BBs$^XD#+v{0nX z(qs76M#3D=H);8wsPtrEtQW=6Y@|r@Arcr4C}de1|?mYvZ9-~ZF53l ztlY^W!#v6Ql~JzQMR-FLV^E!bdR-P;>UWnHk>E5^0)sYs%_-4BVISGVU&rV!-XT|h z<`YyM=(R@%%!=(2L<53Lx=5-P+l}F4*?mWkeKHa1?-NSZZHN2mjsGl_Y!0LM7T~9b zDAQ&NVDH8lcBSkrZWZufaB@PU>7J=*W4pQZpMVW^>}N6#%#+vRg?`s}(W`;qNE=Hu zUh%8vT`jyy)wfGzrUO=**j5@0nITQLj?=lugeFK)56?&QDXi|L1I944%^9$NQ>k&c zWbm0T(e8Y#U^=6ZQqaqA0Cstr1`D2Vn#V#MXG|rOsH4gqDaW}VnH&mQ{`$pfR5{Ci zqk`|gzOMQiP1zjn9Fs@FerHCqILP-Jf(E$V95xZ>c4 zPgot4z=B`^Ze3lAhhT(G+5YHn;8(h-Xj&znE@cizpUZeslfM4G>Y`(fD-RV-=S)kW zSD^9hFVs_dM%{I>-U)0F~5eFm)^zkn2~JHWsFa zB4AuMAb-+kj}s|b08n`I+9c3-OKM|m{nA<(ZueQ5H=e@}(-Mh;@wXnNnez4IRgNSB z$!k^mR9CChBgu<{TL1(HV8c7}fCrxJm~2aVqqo4IjhSTjDv3^xok&g5YiyNCF@Sg9 z&WUcMU-*~v*#<7XMmCVPbe|393G;E1Z*$ej8aW037w_FtQy+n9i{e&oYU`0jrG-R$x1$whCrQGbm;abJH9HgF)5tC#6c^y%SNlq#UI6UB9zbk zN~k+;OUD}Sb1~X>wC7~!qUMGmNrormn8omXnk%{BSJ)$;^nHe%r-9tFtLUcyYa=8~ zV8B5;b;mF5B-0CNK*4-`!NP#q^CZ1~KPh~EM-l4e9-{e1l8pI}+J?8Rj(uN+rXd9t z=azr3D_$Z@i5f#p9E|5?7MM&V!qcwm^JfWxptuUt5}-0r>sc^jz8X2($p^uGgGUUg z5SU{65P06~eZ;{Rw_56ivr^_&{Q%b>f65&5b53%x88?W2q*Nfooktwc@C@| zUXp~jq!30Gaa>6=3?opg8d6EBnk<#KQ)oy;i#VOWPpVK(1Tz*8IOY38o@fgc`n`58n?CGJd-5;bjG8D{dn zIQ`%>ToN$UOF5P`C1)&pJb!;*7B&-xx1Fp;WKKpS6lQu#dW`%OtisqJcrKMM41ec! zGi1GgwgHN3(4@o1C(vWj4RV7P!Cl=;2~hQBtJK(Wx$O+qO19CQDDnqM+x*d+CW?)^ z*7*W;k=bk@AlZ)iAo^lKlyjT!4l@jF?}lpsGLss~QqIr8q~peFyL+S)oUM>l7#d@X z;bj8QD2$qb=8(%H6gK@k?B_+0=BJB=vuOpnu8!*oV_0W!xzV2d8i^ zXB1oT0PBp#cCn39sk)f3$#>ic!I7dv=>y>n^_Zj@FlSeK9a&1R;9pOOa1ovg2MB-S{* zGnQYeaIRmpFp3GG1Wbw{v5Y&^iX|&m=i7yjC_1UH;iZ>5B^k1WU#(}0Y&Z29csKXJ zMycAp)Bm2a*G)Y}kK38!i5ShP6TwZnh8PMPEc6Gg?3y{yX7+K)UoOY|7kpr0mYQO* zBh>R9KLdZDqnP@R)m??c1!jaHk9~rm{Q?!Ijp1Iu{#IzUm2XQw4_Q==0e_#@AD1kh ziJ@gZ5#)5h(`PN%u&_D89AT1W@5+Z%^??XBY7`U*G8rb;<8*7&k1f;zLJj4E#}@7e z`48XfnJwd8?q{Lm1_Yj-;#t~tw%HFdY%1RO+Z0<2OiN$Bh$L=q2gp3pI%Z&LHI3qG zD*&G$DYtchhg(meg0HCM>cqU@#2RX-kj2B=^UpR3;D9|pqZ{Iokb3fn%hauB0o{?* z{;-&tJb3F#yn9qbqlVrR-c*;*EOQK5d&;Yd7xu!Rs;tmsttB(S`obKP@--3HM+lTL zVOSrjTPO&~G$PA*2npY^D&n=PFxl$$T!oR(>Eebn{UQG)9FR<+f9P2Dhd|%ulmFX! zCD=#E*`3cNFSz4L7&FN6LO!WNM-+i}^NFSNT+q)^xa%+yD)7uH4LNyY`D=cT*p!_?Ut$=`QlF zv~|MytKU+F6di8Tu=e#lC4y&;EXndE#IQkJyhZ6nQ3|)@Z4QZ^uR!T;gzYwQ1jrLs z^gJ*@3rF8{&HygZ*_~e755iCNr~(oImEaEVDIc zqQ$}}=kmY1d<|!{T^cnySuUn9ZBBY7A`F|BDrq5C^*+G_Qx;val4uvD4vX}PmS zM1Nn92P$;Fl2`AxGs=(y7Tw;MOf$m)`^h4GtQ>O7jP+lgu>sHo)lVnWyVZMhBeR0B zU{#6$ANu>w(EGm~aZ5wJIPT@8@XL;vg>f>`!;h__o7)#z(9^_1;JD3X1}vhC+cjZ0 zzFzxB2#Atcuw#PlB75(fm;`K<`Ni)&i&Yr zOKe$>G%h&2$9ifinCr;01epYD|)S zj`@8kbizA2){^di8|08%G90*CE_OM?-4MB{1vQA3vNW_xZSZI_GdH-#xUB+M-TwUi z!AVj6xzEIRxz-7lM6k9NUv0Q(oAV^^OCpK^jv)BV5_WR4J_B@p;_Qab!Us;?BKp=;;d zcEc1$1%;Z%f3gCs@o+o!hMycaz;Z@7#ZR;N+*%`~o%U2{HiXQA0y~cl?r$qE@c$0g z>B_XabWa!KXE@G-P@yQU@W_n20N7*ROrEdx97I zZmasgGb5OMyd>QiN1+IXCZ{m6@P}&u$Za=!H!@SIOAuI_fAa*-Z*fUVz0k5;8)2f> zl1xC736lDzkQCW+rMLCtH-2_~*rx?budNa$Uwom#3S|W>VE2skMnTU@Xt6ZT$ewg< zRfSBN<4MNn_T{#ip#jxRdA)FoH(q<5RDV$i2&3EesBGrL0`>2ByLiyt-D1-yPDAN^ zrg*naw@Jq(ev^;j77Z)3a<1^*1LvG5$C3_I^(D~cjSwnOLwM254cgeoKY#onh?rQX zKAO3=-ANmXek|4KJj`Y6)5;np&k;CwOh?8a&1W1qm42`!u9BEK)#d%O*nnBEO|nUo zSuJ15Y3nU!#SQ8Z5A{n@{86R=ouv&oH^*g)?d733d`s)&9smd)l`CpFxXYNdS@4O~ z>`(6XUm`8*yV1mq$eOGC$x&ny7fVNKK&S|HY-V2(W71>?4h>#F=!A9}!^edXE3P9; zxR#k8{9h2hBaPC~6}L>?tv(qCKAw%i=!Eet#LGwDKf1B#rx(y|+HZr*huW@smdonb zRQq4gM><|0gR!&Cg!zldi6nL{sL7zw54s<5a~6}JoDU>2es1G{b^dW`0hvYzFlH`+ z_Mg8#F;%+3tjf{Cl0ha1tY5o=+8dcW&7`A4VlRIliMh}qjzUhZ-wOp8yyS8!YgdQd9DwzTc~q_e#%zrS$ygs}jJ0)b02R%gijw>Fk?!Oe zzM@uF;i%;$FiR(%`Z3Z@8&7DER*n>>Se_;*)mkTz)-+p`4%d&2u%NKAudMCFGwVb| zkmSud_8I-Ch{M+upM8p0DaTs2;Z19ED7U_`Osg}SY!liDf33`EL^%X+yCQyB?SY5r z%_jcjxn*jTTxsjI&{o=n>~~*`VgSmNyLy##QU;F6_mXZ_wH9>rsVbycx_7!l<|6Sh z%{DCD7e=hx7^}z_{C#I@WQGi1O3S!klMt@Y%$?j-EU(^k{`V%&RH2Hi$~ zb$qE4v7X>N&1|wXV?-ri8)+(|7qpxZ=|3*+A)uVsN$K5W)eI|LsS)l9zOcfYg!hc2 zg%sUU2qxee0bE?6Fc|=m0I?q$!N44QqWOs$iYyl3)kntH%>#)4ODS%BZB#&rpEG`_ zC@eJ8i+RiDUVk1a6br`3pyb}8%~hFi{ld=1wrPyrKIyjp@k!^a;V1o}VnVdg;^_2R zjLXAq@(*TakK-tx@ghI_GqH6UNlFHbo%As+*nTy1^o&0-D$--qj_obb1iFlju#CIJ zZ;3UGRhy;rIrw)uwb<_)Us6xrV^g*IU$M=W)tZG~_4c=!gMnKLRiVKe%kFh!b9Hsaq%A587=XIuZoTeoZwEKq!5Aq4HuQO-%Jn9_fzxwn zLhgtR)(}{$_|gZT5Y;>~=f}I)c1>W5_1|*b4=pvs0nfqn4O`z8jV;V|s|_3MW#NB` zZT8bxb!u0&4I!IqusiFO2O0F8$Iac{dzCVVp7-yK3F|%epioGVk>c6%vZxuo+^k%j zq+};?(VEw^#mW{_$bbf;>XPtg=Z2OTIw9Jd4`WN1(TUb;0Sd~978t%*JlUre5B~52 zi51V&#WqL@q`_UUx1=*0A8%U+rx=%+m9?lwuj$FlJtWhw*}eCZYm+rutg+pNBjPKQ zll{d5A1$JCZ96wVkAbx%wJN@)ExJ^>DkPKji`#8DtG0tQY}3@qiFmQf>e`to`cXwu zKUE>J*Y7${^&z<|pw;nN?DzMA+j7gXU!$zMrz?J@WJzY!$s@mR9dCX&fx%!84|_-f z{^64dtW@2u&Ec%@jh(Fl7eD`;@PqO8-fLv+y5I8x!(dnyWtR1GutblnYYYgMuvS<1 ztY>F;=t`ON9=>%hr*z)f)^K`wrUeHJGxQs#> zi%OfQAYUNh6bovyIGnG^JJ>l$x)$mUxE}al8b-f_Y3;Req}D=8jc+_|DD4qgY!<(J|E| z>t)ZzW;1taf846rk4IDqBrRGCdTMtyO-V_CPUz}fpUw+V)I#}QseP-Wv=?Gw891?X zc#Q^PRWvdZW@Z6CX~$YZ?ad=Dm32}sM%hE9Hqyxi(DC(R6~xaCd?N8^6PY=NIqN|T z+$BA`*moR!C{qCI>A`!B;P(lu9Ra)d(^gMpDf#&ml0q~^rRjzoO#A}@@3^>do1I;w zh@wDgy&MMZKp~-lO|$!t#xqEFdP-`D;eC_FBW6vD5Q4tYqDmqG)5{J_=Ct|>3v%mc zI0x>UCdTIGk)fdPOoVcoO1@Fx&2U5N-sPmlzPm3?Wt35dsLvD^e{_jGw-6p0bax`Z z>2lX3u*EMeYsqRgWFWv+&+y=6=W4x-CE4?9d;5<6i4zzEhNipNym*yf>a^uV7A~*X zQ=zt8TrD~==f3{WgV7R2q6yG!^APp000Nq4ov(MZzsJUDbf2r+MYRhb_8?fQB)YF9 zNUF4Ve7s?)!$%q+3N=fKe{b)%==1MjG5m=qk3f);eGW0v-dr<9WmqN?1Cy80-62}r zw{JE0lfG{;gee0#3(XvQFCT8v@1Kos?q#c^LRHS6wNxg?N4R8XWu%Y9((MvLxl_pU z38lW_6U=FA%Y1!iUdy5O7Ny$VI!+DgwiV+t?s{j?=svL8-(MVf{Z_PVeSYNHMq>%v z%*;%DbO5`kEnK1%!?Vcs{!|j3mbMW5201^c(eiTi@|3g5b7e4B_#p@H%~a>z&GnQq zP`g6&Yrs7=$9pZNyKk=^TfZC&K0C$HJ!ypOp;hkg#*>R2+ys9L4^mWAbef(4Ouk`E zuE-HTF6)*{kW|fMY))h)@J7pgP>~mOAO5n$QCIoLTgt^sV$IphtF7`t%SR~RNKI`o z1e)~A$Ia^b4cPkqa?LVjflJ-X#3M+9K!foOpT?LLqrzfo@s40Lq2uThum>JZ3}4)6U@-J|Z7 z7A>v)jr~Qk&g5#;b{98-s1JYb^i1kp19WuKZ|WMb(LxcuXm1EwF_i>#<^gD-Q`EE6 zMA0L%E<-<)k|4+g$R5wjrwy$QgK_9jH<8KsGPIMu+)aVEnrua7o(%22lfj(y1M4ZYPx$No3AHtT+FZpX804-`5cZiCjGol7q9ON zB;xG5JPuVvlPk)8k{BFJ7Rl!I$I)DU_*I^``@2JWb1NSsgV!f3JJhEs-6KG9B5$Rx zz?&HnU;|K-x}j%exIZNMr6|(u$&;Nu{wbj8`C8iI|OH|i_mh#^T^SXiXK z?1#46p?z}`+I@6n$kX#twerIwVsp>>qsO+Z(8g?4jAdM0oKFX@m(LyV_vMk1hWf^K zF|h4{8;>Tp^MO8bYM>~O#4|FXw)dWFO7~$hf6N2zI1WNf3wC@#+P4k_` zn=@+0`cL?iAotbWv52$ zB}n5YCOEBI&)eXz`e?wsw4=NK`F6#8onp|U(WBIlQxQ+)NWX`#Z|~%!C0$nI@tKZ} zmfNS3M=eeyysfYA<8p&%+w7+L-+EpjZv~N$BjRiq6&H&(b@?9Om4DP?-T5lm#*EfW zywO2teVs)m*0EyMq#I?+)E+8TQQZL0uHySVHO1{V1`}E18ylME4ca~FMn}dx3Uc}L z<(1d@>Nbyn{X!SZk)wC#YPT;~hl36;Qc!*JS~gEOVCVLFYsffF!2%=r{mr0r!2GEPrF1SJUE<-r;_wxZE0m ztXH81&5b39Z4ZJ}q|6macQV=Y8+`7@z-%X#E5!zrbMf#5#3*FRsHntM@S&Gmeian4 z^6_q-;ZKzPvC?n+wgRg29C&nIRegsTDL*nswkbL?KF-zYe|vz@97N3Y@a~BlWeTOZ zwA51}=ypm>Kw$pipe2Z`81*VmnVb=}wAAHhWXw#US8LREW_8r9P(m9xvgN+_JCQ|+ zQ6oF~1x*|ypFq967)ktW0s^N(cwQQ(#$qZjkX2BbwX)0o7&4I?KN(^j2%&ueQuUEQKZt0qWx zLt5;N1z$3X9A3zjda4#1hKK=d_x}1g3le0$+=76BDDjUL0Cx4@v9tXnOt7?Ep@P`= zVh(|zN$ev~o9=^|!0HM2G2&APEy#a&6 zq{zt+0rYn2=uAT~w&!UAZEf*CCnmfkp&9%2=6H`&Hw-83;&{__`gXm{v*ctck7F8<6ma}k5xSV`L$Ls WJti%64eyJ(1VLU#1yn0#8uDMIk*;+B literal 0 HcmV?d00001 diff --git a/sunspec/sunspec.pro b/sunspec/sunspec.pro new file mode 100644 index 0000000..df3837a --- /dev/null +++ b/sunspec/sunspec.pro @@ -0,0 +1,14 @@ +include(../plugins.pri) +include(../sunspec.pri) + +SOURCES += \ + integrationpluginsunspec.cpp \ + solaredgebattery.cpp \ + sunspecdiscovery.cpp \ + sunspecthing.cpp + +HEADERS += \ + integrationpluginsunspec.h \ + solaredgebattery.h \ + sunspecdiscovery.h \ + sunspecthing.h diff --git a/sunspec/sunspecdiscovery.cpp b/sunspec/sunspecdiscovery.cpp new file mode 100644 index 0000000..a38acaf --- /dev/null +++ b/sunspec/sunspecdiscovery.cpp @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "sunspecdiscovery.h" + +#include +#include +#include + +SunSpecDiscovery::SunSpecDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery, quint16 slaveId, SunSpecDataPoint::ByteOrder byteOrder, QObject *parent) + : QObject{parent}, + m_networkDeviceDiscovery{networkDeviceDiscovery}, + m_slaveId{slaveId}, + m_byteOrder{byteOrder} +{ + m_scanPorts.append(502); + m_scanPorts.append(1502); +} + +QList SunSpecDiscovery::results() const +{ + return m_results; +} + +void SunSpecDiscovery::addCustomDiscoveryPort(quint16 port) +{ + if (m_scanPorts.contains(port)) + return; + + m_scanPorts.append(port); +} + +void SunSpecDiscovery::startDiscovery() +{ + qCInfo(dcSunSpec()) << "Discovery: Start searching for SunSpec devices in the network..."; + NetworkDeviceDiscoveryReply *discoveryReply = m_networkDeviceDiscovery->discover(); + + m_startDateTime = QDateTime::currentDateTime(); + + // Imedialty check any new device gets discovered + connect(discoveryReply, &NetworkDeviceDiscoveryReply::hostAddressDiscovered, this, &SunSpecDiscovery::checkNetworkDevice); + + // Check what might be left on finished + connect(discoveryReply, &NetworkDeviceDiscoveryReply::finished, discoveryReply, &NetworkDeviceDiscoveryReply::deleteLater); + connect(discoveryReply, &NetworkDeviceDiscoveryReply::finished, this, [this, discoveryReply](){ + qCDebug(dcSunSpec()) << "Discovery: Network discovery finished. Give some time for pending discovery checks to finish..."; + m_networkDeviceInfos = discoveryReply->networkDeviceInfos(); + + // Give the last connections added right before the network discovery finished a chance to check the device... + QTimer::singleShot(3000, this, [this](){ + finishDiscovery(); + }); + }); +} + +void SunSpecDiscovery::testNextConnection(const QHostAddress &address) +{ + if (!m_pendingConnectionAttempts.contains(address)) + return; + + SunSpecConnection *connection = m_pendingConnectionAttempts[address].dequeue(); + if (m_pendingConnectionAttempts.value(address).isEmpty()) + m_pendingConnectionAttempts.remove(address); + + qCDebug(dcSunSpec()) << "Discovery: Start searching on" << QString("%1:%2").arg(address.toString()).arg(connection->port()) << "slave ID:" << connection->slaveId(); + // Try to connect, maybe it works, maybe not... + if (!connection->connectDevice()) { + qCDebug(dcSunSpec()) << "Discovery: Failed to connect to" << QString("%1:%2").arg(address.toString()).arg(connection->port()) << "slave ID:" << connection->slaveId() << "Continue...";; + cleanupConnection(connection); + return; + } + + /* Some connection will block the connection process and the connection will pending in the + * connecting state for over a minute before failing. + * This behavior blocks further connection tests for during the disdovery process and the discovery failes to detect the device. + * This timer will make sure the connection can be established within 5 seconds, otherwise we continue with the next connection. + */ + + QTimer *connectionTimer = new QTimer(connection); + connectionTimer->setInterval(5000); + connectionTimer->setSingleShot(true); + + m_connectionTimers.insert(connection, connectionTimer); + + connect(connectionTimer, &QTimer::timeout, connection, [this, connection, connectionTimer](){ + qCDebug(dcSunSpec()) << "Discovery: Could not establish a connection within" << connectionTimer->interval() << "ms. Continue..."; + m_connectionTimers.remove(connection); + connection->disconnectDevice(); + // Note: since the connection is the parent of the timer, it will be deleted with the connection + cleanupConnection(connection); + }); + + // Once connected, the timer will be stopped. + connectionTimer->start(); +} + +void SunSpecDiscovery::checkNetworkDevice(const QHostAddress &address) +{ + // Create a connection queue for this network device + + QQueue connectionQueue; + + // Check all ports for this host + foreach (quint16 port, m_scanPorts) { + + + SunSpecConnection *connection = new SunSpecConnection(address, port, m_slaveId, m_byteOrder, this); + connection->setNumberOfRetries(1); + connection->setTimeout(500); + m_connections.append(connection); + connectionQueue.enqueue(connection); + + connect(connection, &SunSpecConnection::connectedChanged, this, [this, connection, connectionQueue, address](bool connected){ + if (!connected) { + // Disconnected ... done with this connection + cleanupConnection(connection); + return; + } + + // Successfully connected, we can stop the connection timer which takes care about blocking connection attempts + if (m_connectionTimers.contains(connection)) { + QTimer *connectionTimer = m_connectionTimers.take(connection); + connectionTimer->stop(); + connectionTimer->deleteLater(); + } + + // Modbus TCP connected, try to discovery sunspec models... + connect(connection, &SunSpecConnection::discoveryFinished, this, [=](bool success){ + if (!success) { + qCDebug(dcSunSpec()) << "Discovery: SunSpec discovery failed on" + << QString("%1:%2").arg(address.toString()).arg(connection->port()) + << "slave ID:" << connection->slaveId() << "Continue..."; + cleanupConnection(connection); + return; + } + + // Success, we found some sunspec models here, let's read some infomation from the models + + Result result; + result.address = address; + result.port = connection->port(); + result.slaveId = connection->slaveId(); + + qCDebug(dcSunSpec()) << "Discovery: --> Found SunSpec devices on" << result.networkDeviceInfo << "port" << result.port << "slave ID:" << result.slaveId; + foreach (SunSpecModel *model, connection->models()) { + if (model->modelId() == SunSpecModelFactory::ModelIdCommon) { + SunSpecCommonModel *commonModel = qobject_cast(model); + QString manufacturer = commonModel->manufacturer(); + if (!manufacturer.isEmpty() && !result.modelManufacturers.contains(manufacturer)) { + result.modelManufacturers.append(manufacturer); + } + } + } + + m_results.append(result); + + // Done with this connection + cleanupConnection(connection); + }); + + // Run SunSpec discovery on connection... + if (!connection->startDiscovery()) { + qCDebug(dcSunSpec()) << "Discovery: Unable to discover SunSpec data on connection" + << QString("%1:%2").arg(address.toString()).arg(connection->port()) + << "slave ID:" << connection->slaveId() << "Continue..."; + cleanupConnection(connection); + } + }); + + // If we get any error...skip this host... + connect(connection->modbusTcpClient(), &QModbusTcpClient::errorOccurred, this, [=](QModbusDevice::Error error){ + if (error != QModbusDevice::NoError) { + qCDebug(dcSunSpec()) << "Discovery: Connection error on" + << QString("%1:%2").arg(address.toString()).arg(connection->port()) + << "slave ID:" << connection->slaveId() << "Continue..."; + cleanupConnection(connection); + } + }); + } + + m_pendingConnectionAttempts[address] = connectionQueue; + testNextConnection(address); +} + +void SunSpecDiscovery::cleanupConnection(SunSpecConnection *connection) +{ + m_connections.removeAll(connection); + connection->disconnectDevice(); + connection->deleteLater(); + + if (m_connectionTimers.contains(connection)) { + QTimer *connectionTimer = m_connectionTimers.take(connection); + connectionTimer->stop(); + } + + testNextConnection(connection->hostAddress()); +} + +void SunSpecDiscovery::finishDiscovery() +{ + qint64 durationMilliSeconds = QDateTime::currentMSecsSinceEpoch() - m_startDateTime.toMSecsSinceEpoch(); + + // Fill in all network device infos we have + for (int i = 0; i < m_results.length(); i++) + m_results[i].networkDeviceInfo = m_networkDeviceInfos.get(m_results.at(i).address); + + // Cleanup any leftovers...we don't care any more + foreach (SunSpecConnection *connection, m_connections) + cleanupConnection(connection); + + qCInfo(dcSunSpec()) << "Discovery: Finished the discovery process. Found" << m_results.length() + << "SunSpec devices in" << QTime::fromMSecsSinceStartOfDay(durationMilliSeconds).toString("mm:ss.zzz"); + emit discoveryFinished(); +} diff --git a/sunspec/sunspecdiscovery.h b/sunspec/sunspecdiscovery.h new file mode 100644 index 0000000..0037c95 --- /dev/null +++ b/sunspec/sunspecdiscovery.h @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef SUNSPECDISCOVERY_H +#define SUNSPECDISCOVERY_H + +#include +#include +#include + +#include +#include + +class SunSpecDiscovery : public QObject +{ + Q_OBJECT +public: + explicit SunSpecDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery, quint16 slaveId, SunSpecDataPoint::ByteOrder byteOrder = SunSpecDataPoint::ByteOrderLittleEndian, QObject *parent = nullptr); + typedef struct Result { + QHostAddress address; + NetworkDeviceInfo networkDeviceInfo; + quint16 port; + quint16 slaveId; + QStringList modelManufacturers; + } Result; + + QList results() const; + + void addCustomDiscoveryPort(quint16 port); + void startDiscovery(); + +signals: + void discoveryFinished(); + +private: + NetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr; + QList m_scanPorts; + quint16 m_slaveId = 1; + SunSpecDataPoint::ByteOrder m_byteOrder; + + QDateTime m_startDateTime; + QHash> m_pendingConnectionAttempts; + QHash m_connectionTimers; + QList m_connections; + NetworkDeviceInfos m_networkDeviceInfos; + QList m_results; + + void testNextConnection(const QHostAddress &address); + + void checkNetworkDevice(const QHostAddress &address); + void cleanupConnection(SunSpecConnection *connection); + + void finishDiscovery(); +}; + +#endif // SUNSPECDISCOVERY_H diff --git a/sunspec/sunspecthing.cpp b/sunspec/sunspecthing.cpp new file mode 100644 index 0000000..f8b27cb --- /dev/null +++ b/sunspec/sunspecthing.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "sunspecthing.h" + +SunSpecThing::SunSpecThing(Thing *thing, SunSpecModel *model, QObject *parent) : + QObject(parent), + m_thing(thing), + m_model(model) +{ + +} + +SunSpecConnection *SunSpecThing::connection() const +{ + if (!m_model) + return nullptr; + + return m_model->connection(); +} + +SunSpecModel *SunSpecThing::model() const +{ + return m_model; +} + +Thing *SunSpecThing::thing() const +{ + return m_thing; +} + +quint16 SunSpecThing::modbusStartRegister() const +{ + if (!m_model) + return 0xFFFF; + + return m_model->modbusStartRegister(); +} + +void SunSpecThing::executeAction(ThingActionInfo *info) +{ + Q_ASSERT_X(false, "executeAction", QString("Unhandled action: %1").arg(info->action().actionTypeId().toString()).toUtf8()); + info->finish(Thing::ThingErrorActionTypeNotFound); +} + +void SunSpecThing::onBlockDataUpdated() +{ + // Reimplement if required +} diff --git a/sunspec/sunspecthing.h b/sunspec/sunspecthing.h new file mode 100644 index 0000000..b21455c --- /dev/null +++ b/sunspec/sunspecthing.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* This file is part of nymea-plugins-modbus. +* +* nymea-plugins-modbus 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-plugins-modbus 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-plugins-modbus. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef SUNSPECTHING_H +#define SUNSPECTHING_H + +#include + +#include "extern-plugininfo.h" + +#include + +#include +#include + +class SunSpecThing : public QObject +{ + Q_OBJECT +public: + explicit SunSpecThing(Thing *thing, SunSpecModel *model, QObject *parent = nullptr); + virtual ~SunSpecThing() = default; + + virtual SunSpecConnection *connection() const; + SunSpecModel *model() const; + Thing *thing() const; + + virtual quint16 modbusStartRegister() const; + + virtual void readBlockData() = 0; + + virtual void executeAction(ThingActionInfo *info); + +protected: + Thing *m_thing = nullptr; + SunSpecModel *m_model = nullptr; + +private slots: + virtual void onBlockDataUpdated(); + +}; + +#endif // SUNSPECTHING_H diff --git a/sunspec/test/sunspec_server.sh b/sunspec/test/sunspec_server.sh new file mode 100755 index 0000000..fb09118 --- /dev/null +++ b/sunspec/test/sunspec_server.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +if ! command -v suns &> /dev/null +then + echo "suns could not be found" + echo "... installing" + sudo apt update + sudo apt install libmodbus-dev flex bison git + + if [ ! -d "sunspec"]; then + git clone https://github.com/Boernsman/sunspec.git + fi + cd ./sunspec/src + make + sudo make install +fi + +echo "Startin sunspec test server" +sudo suns -s -vvvv -m models/test/composite_superdevice.model diff --git a/sunspec/translations/cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b-en_US.ts b/sunspec/translations/cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b-en_US.ts new file mode 100644 index 0000000..62d6250 --- /dev/null +++ b/sunspec/translations/cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b-en_US.ts @@ -0,0 +1,976 @@ + + + + + IntegrationPluginSunSpec + + + The discovery is not available. + + + + + The SunSpec connection is not connected. + + + + + Single Phase Inverter + + + + + Split Phase Inverter + + + + + Three Phase Inverter + + + + + Single Phase Meter + + + + + Split Phase Meter + + + + + Three Phase Meter + + + + + Storage + + + + + The SunSpec discovery finished with errors. Please make sure this is a SunSpec device. + + + + + Ground fault + + + + + DC over voltage + + + + + AC disconnect + + + + + DC disconnect + + + + + Grid disconnect + + + + + Cabinet open + + + + + Manual shutdown + + + + + Over temperature + + + + + Over frequency + + + + + Under frequency + + + + + AC over voltage + + + + + AC under voltage + + + + + Blown string fuse + + + + + Under temperature + + + + + Memory loss + + + + + Hardware test failure + + + + + No error + + + + + SunSpec + + + + + AC energy + The name of the StateType ({d493880d-eb58-4530-8010-8ea4f6d63387}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({fe3f8a65-121a-4ae1-b22a-ae325dc3e7e6}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({4c0407b3-5cd5-438d-bfa8-9a8d6695b458}) of ThingClass sunspecSinglePhaseInverter + + + + + + + AC power + The name of the StateType ({14036f44-25fd-4e93-8e8c-c677b06a2c34}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({9235eb4b-906c-4557-8e18-bca268a367cc}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({f49591d6-d759-4be3-bafc-b6a7a72cf023}) of ThingClass sunspecSinglePhaseInverter + + + + + Available energy + The name of the StateType ({7ac7ccd1-b5d1-488b-a99b-98ef000b7d03}) of ThingClass solarEdgeBattery + + + + + Average temperature + The name of the StateType ({f46298e5-d06c-4ebe-869a-d9feff1a4689}) of ThingClass solarEdgeBattery + + + + + + Battery critical + The name of the StateType ({897616db-e815-4cda-b890-13b37a94ad17}) of ThingClass solarEdgeBattery +---------- +The name of the StateType ({3171a6e0-43a7-4de8-8e20-f748e44af7ac}) of ThingClass sunspecStorage + + + + + + Battery level + The name of the StateType ({a4b0c8c9-44f0-43e2-ab25-63fdc9772fb8}) of ThingClass solarEdgeBattery +---------- +The name of the StateType ({0bf53f80-97f8-488b-b514-58f9fe08c183}) of ThingClass sunspecStorage + + + + + Battery status + The name of the StateType ({9c24b27a-3ea1-4db1-8868-6c999aad0a18}) of ThingClass solarEdgeBattery + + + + + + + Cabinet temperature + The name of the StateType ({44b0320f-89a7-4248-bad4-288ef898a5cc}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({6d314d50-b990-4a58-a37f-4a3da42c4407}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({51461bde-1a6b-4aa1-94cc-59829ea0a7c8}) of ThingClass sunspecSinglePhaseInverter + + + + + + Charging + The name of the ParamType (ThingClass: sunspecStorage, ActionType: enableCharging, ID: {1f530f79-c0d2-466b-90e1-79149e34d92f}) +---------- +The name of the StateType ({1f530f79-c0d2-466b-90e1-79149e34d92f}) of ThingClass sunspecStorage + + + + + + Charging rate + The name of the ParamType (ThingClass: sunspecStorage, ActionType: chargingRate, ID: {7f469bbc-64a5-4045-8d5f-9a1a85039851}) +---------- +The name of the StateType ({7f469bbc-64a5-4045-8d5f-9a1a85039851}) of ThingClass sunspecStorage + + + + + + Charging state + The name of the StateType ({815b0826-c392-4249-a271-19e92ebe46e0}) of ThingClass solarEdgeBattery +---------- +The name of the StateType ({f6178427-8e04-45c7-abec-27d04dd5ff04}) of ThingClass sunspecStorage + + + + + + + + + + + + + + Connected + The name of the StateType ({8addf8aa-f879-4afa-935d-4d2ffa38d05e}) of ThingClass solarEdgeBattery +---------- +The name of the StateType ({f51d4e5a-24ea-41f9-a453-d2bdd68d804c}) of ThingClass solarEdgeConnection +---------- +The name of the StateType ({25a1fb10-a6b9-4037-b7cf-ad481a65beb4}) of ThingClass sunspecStorage +---------- +The name of the StateType ({36f861c7-afc1-4725-b41f-67113200d78f}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({34e34ec9-dab0-438c-9493-a3068bc401de}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({d960e7b1-d4aa-4cab-8f54-6bcfdbb8be36}) of ThingClass sunspecSinglePhaseMeter +---------- +The name of the StateType ({4401468c-0385-40a9-b436-daf7ed6a50d5}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({27b49640-f58b-466e-a225-a4663cf3ed96}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({48bf31c4-fda7-41e5-a3ef-3011bf96e104}) of ThingClass sunspecSinglePhaseInverter +---------- +The name of the StateType ({3e767ad0-b4b3-4398-94c1-00579ea09ca8}) of ThingClass sunspecConnection + + + + + + Current power phase A + The name of the StateType ({0e3e8870-3d59-4ee8-b7aa-33bda68b6ae3}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({57c0f5ba-a104-424b-b8d4-8bab90acf009}) of ThingClass sunspecSplitPhaseMeter + + + + + + Current power phase B + The name of the StateType ({453d046c-3a66-4590-882c-1283882918fc}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({456f7cdf-9756-49b8-9084-f212199d4014}) of ThingClass sunspecSplitPhaseMeter + + + + + Current power phase C + The name of the StateType ({ba4e94de-9136-48a7-aad8-3d5cd0e7d968}) of ThingClass sunspecThreePhaseMeter + + + + + + + DC current + The name of the StateType ({81a989aa-4104-438e-bfc5-4f44ce5ceeaf}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({68e7239f-d7b2-4ab2-9cf3-e769fb5ddfc0}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({79bd1748-b540-467d-b6fa-b0cc56d9775a}) of ThingClass sunspecSinglePhaseInverter + + + + + + + DC power + The name of the StateType ({1397d935-1206-4ceb-883d-1fcf41ba6214}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({560b5ec8-1b2c-47d3-b55e-40f4d0aebfe0}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({852a8c28-b14d-492e-b2b3-f22219f4ed36}) of ThingClass sunspecSinglePhaseInverter + + + + + + + DC voltage + The name of the StateType ({8c1c71c4-28fd-472a-83a6-3e96ab03d677}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({d36bcaba-1e77-431e-89dc-af8ac6172d7d}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({157376b8-19ae-415f-b693-cb0a9ba88863}) of ThingClass sunspecSinglePhaseInverter + + + + + + + + + + + + Device model + The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {e90a3833-1eb6-43ed-a72a-0cf7cb8015ca}) +---------- +The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {2abf786d-18be-46cb-b4a3-7e0934a4c3fd}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {00d308dc-e9a3-40e4-acad-3cad2ca3590f}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {6bc978cb-a779-4f5f-87a4-3cbbe2a55ca8}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {e31333c8-746d-4955-af0a-ae18d7100de7}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {938b6555-e0af-4222-b85b-90bc1bc78c8f}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {d40b1b9d-994d-422c-8a41-b98cf03c1678}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {12f1b02c-033d-423f-a217-955ddb995dd5}) + + + + + + Discharging + The name of the ParamType (ThingClass: sunspecStorage, ActionType: enableDischarging, ID: {bc99a159-815a-40ab-a6e8-b46f315305f7}) +---------- +The name of the StateType ({bc99a159-815a-40ab-a6e8-b46f315305f7}) of ThingClass sunspecStorage + + + + + + Discharging rate + The name of the ParamType (ThingClass: sunspecStorage, ActionType: dischargingRate, ID: {6068f030-acce-44a2-b95f-bd00dd5ca760}) +---------- +The name of the StateType ({6068f030-acce-44a2-b95f-bd00dd5ca760}) of ThingClass sunspecStorage + + + + + Enable charging + The name of the ActionType ({1f530f79-c0d2-466b-90e1-79149e34d92f}) of ThingClass sunspecStorage + + + + + Enable discharging + The name of the ActionType ({bc99a159-815a-40ab-a6e8-b46f315305f7}) of ThingClass sunspecStorage + + + + + + Energy consumed phase A + The name of the StateType ({cc0fd197-22ed-4461-8bba-47460396798a}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({481b9895-1c3e-4e74-9418-ee51dae80a63}) of ThingClass sunspecSplitPhaseMeter + + + + + + Energy consumed phase B + The name of the StateType ({e928fde3-b35b-4884-9810-1cd083725e1c}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({10245c6c-4b0d-44b4-be10-72026b842c86}) of ThingClass sunspecSplitPhaseMeter + + + + + Energy consumed phase C + The name of the StateType ({2a12a991-d6c8-4665-bcc8-ea2685f7f0d4}) of ThingClass sunspecThreePhaseMeter + + + + + + Energy produced phase A + The name of the StateType ({fe7caa7e-cf37-4550-833e-7f9d35001fdd}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({2dc76576-561c-4bce-8799-fadc55e84aee}) of ThingClass sunspecSplitPhaseMeter + + + + + + Energy produced phase B + The name of the StateType ({edbe64a5-121e-4c96-9dc8-bbc2a33dfdb7}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({b1489efd-ece0-419e-ab1c-d3d25ab6b958}) of ThingClass sunspecSplitPhaseMeter + + + + + Energy produced phase C + The name of the StateType ({a26790c0-7378-471c-80ba-67021352415d}) of ThingClass sunspecThreePhaseMeter + + + + + + + Error + The name of the StateType ({4479af96-c499-4f15-abd6-4afdb18a9e09}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({5cbfccc9-6afb-404c-a85e-e0323659a25f}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({49240259-d82a-4fe6-b3f5-1cd6a67c87a7}) of ThingClass sunspecSinglePhaseInverter + + + + + + + + + Frequency + The name of the StateType ({66734fc7-b3e4-48d4-8cb7-f0a9f7cef88a}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({db977c04-a3e1-436f-a0cd-9ce5b7bc6b89}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({3a2ce51d-7fa0-4188-bbd6-00d25de90e15}) of ThingClass sunspecSinglePhaseMeter +---------- +The name of the StateType ({faa45cae-ed28-4150-9036-fceddf9d6776}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({611df2ce-2b9c-49f3-9fa7-5706776e812c}) of ThingClass sunspecSinglePhaseInverter + + + + + + Grid charging + The name of the ParamType (ThingClass: sunspecStorage, ActionType: gridCharging, ID: {221a2ef6-0a92-4ff0-87fe-7bd920dbec0b}) +---------- +The name of the StateType ({221a2ef6-0a92-4ff0-87fe-7bd920dbec0b}) of ThingClass sunspecStorage + + + + + + IP address + The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {94f6ba3c-6b8b-47a9-a5cb-73a091ae4cf7}) +---------- +The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {6be6abc4-e2b2-4687-9343-0e5164ed0ab2}) + + + + + Instantaneous current + The name of the StateType ({89f58155-ca0c-48a8-aa7f-6e4d1b1bad36}) of ThingClass solarEdgeBattery + + + + + Instantaneous power + The name of the StateType ({caccf9fd-0f83-4dd5-ae48-a497f13d2a2e}) of ThingClass solarEdgeBattery + + + + + Instantaneous voltage + The name of the StateType ({9d17defb-fc6e-4c10-af53-14b796222a14}) of ThingClass solarEdgeBattery + + + + + Line frequency + The name of the StateType ({874f5e4a-a009-4c28-b211-2af90a24b2ac}) of ThingClass sunspecSplitPhaseInverter + + + + + Line to Neutral AC Voltage + The name of the StateType ({0ac79508-07c3-4d01-97a3-6edf121bdf32}) of ThingClass sunspecSplitPhaseMeter + + + + + + MAC address + The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {bb395c12-54d6-4139-b0a6-e31b28bc4d2e}) +---------- +The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {f65d6c36-1672-44cb-b52a-62d71837ae67}) + + + + + + + + + + + + Manufacturer + The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {8a4b19a9-ee14-42aa-9146-fa84e15970ee}) +---------- +The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {7ee31b97-caec-4cdb-80c9-71e232c5fb22}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {462552de-af84-4893-9132-81c1adce67ad}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {ecbdee01-8be7-42bf-9e1a-a0f700669b09}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {811de380-848a-448c-8446-daa0e0fd8e50}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {640b71ba-2ccd-40d6-bf45-8271a9046f81}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {c2c35b68-1c0f-47cc-b09f-49d9cf67c0ff}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {5fbb0b62-e0f9-4aee-aba7-fd00ae8b0a5d}) + + + + + Maximum energy + The name of the StateType ({d8cc799d-9709-4342-8766-52ff5f12aecc}) of ThingClass solarEdgeBattery + + + + + + + + + + + + Modbus address + The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {7413cce6-48c1-4924-89bc-915beed67b57}) +---------- +The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {3f107844-00c5-4f39-86e5-485b3d1f5c1a}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {6d5dbd35-1bf6-46db-bee9-90c679421b89}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {a56f198d-ed86-429f-b839-8e11a32da8c1}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {30b90ec0-429b-4e6c-88e9-155aa4bcad47}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {e5465ede-9d3d-4558-b614-40dda743ddae}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {37582a96-f2f2-4845-abef-973c7dd0ad57}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {26ae9050-7090-453a-85a3-307bfebe6fed}) + + + + + + + + + + + Model + The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {219beb96-b9fe-4dd2-a386-ecfbbab8786d}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {a1960821-155c-4176-86fa-974429039182}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {89aeec6d-abeb-48b5-9594-214ad5db2d03}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {7d6fcafb-c62e-4a21-aae2-f4041c487149}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {8d5b2b58-ce46-406d-844e-f53136afcf09}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {c42fb50e-210f-4b53-88eb-fa216e15f88f}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {41715d00-a947-4f43-a475-cea05790e01d}) + + + + + Number of retries + The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {9a4bfe01-315f-4ee7-98a9-f16b08ba12ad}) + + + + + + + Operating state + The name of the StateType ({cebdce98-42d1-4a28-8834-8960efc0e83f}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({6c1e2929-bc9a-4ce9-a405-6df2633a5131}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({47543a7f-425f-406b-a458-b79c36b65f6c}) of ThingClass sunspecSinglePhaseInverter + + + + + + + + + Phase A current + The name of the StateType ({46ae5c40-f389-4f7e-a59f-8947d7dc9f3f}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({e8c0f4bf-a704-46f2-80a0-cf490bd7871b}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({4a058e36-0b45-4388-9a26-0615f7aafa0d}) of ThingClass sunspecSinglePhaseMeter +---------- +The name of the StateType ({3140ccd3-40cf-46c8-8bb2-8c3ea4582f84}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({be7b86b4-aeeb-49ba-9b6b-9792dceed6b5}) of ThingClass sunspecSplitPhaseInverter + + + + + + + Phase A voltage + The name of the StateType ({4ff342de-f0b7-475a-99e4-3a926326d1b7}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({1bd7e53e-abf8-4d62-b87c-2c84c283567b}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({5e7a65a5-b740-4395-87fa-ffbe8fe1c823}) of ThingClass sunspecSinglePhaseMeter + + + + + + Phase AN voltage + The name of the StateType ({f08521aa-9c38-4c31-95e1-acb616f6e9c6}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({17c24cfc-cb41-4873-82b4-19a20d6be146}) of ThingClass sunspecSplitPhaseInverter + + + + + + + + Phase B current + The name of the StateType ({efc75436-ac51-4fee-81f1-65a726416980}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({4281f6fc-d5a0-4a22-ac61-6bec88efbc80}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({7ea1a53a-6fd9-4914-8283-b57aa1aaaebf}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({fc5df18d-cf2f-4944-97b7-e57dabef8778}) of ThingClass sunspecSplitPhaseInverter + + + + + + Phase B voltage + The name of the StateType ({cc8434f1-02fe-41ad-bc8a-94e57293f8fc}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({377b5279-ddb6-451d-8377-a9389c749393}) of ThingClass sunspecSplitPhaseMeter + + + + + + Phase BN voltage + The name of the StateType ({739b8805-d522-4406-bede-d1e4200a3aa9}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({f8645ee2-a1e6-4d09-8c20-f6fd02a9e896}) of ThingClass sunspecSplitPhaseInverter + + + + + + Phase C current + The name of the StateType ({618cbcc5-2efe-49d2-9c5c-53fb3eaa3a9d}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({aa4b4cf5-43d0-4be5-9505-403918b5371d}) of ThingClass sunspecThreePhaseInverter + + + + + Phase C voltage + The name of the StateType ({fbd673cf-8891-4aef-b112-2829f5fcfe06}) of ThingClass sunspecThreePhaseMeter + + + + + Phase CN voltage + The name of the StateType ({949b797d-5566-4667-8982-e430d23548e2}) of ThingClass sunspecThreePhaseInverter + + + + + Phase voltage + The name of the StateType ({4ca086e9-82b9-461c-b168-1d61b542b884}) of ThingClass sunspecSinglePhaseInverter + + + + + + Port + The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {1bcede48-d167-4ced-8f1b-bea6302dd43f}) +---------- +The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {1fa4fc9c-f6be-47c7-928a-bcefc1142eec}) + + + + + Rated energy + The name of the StateType ({f6c6f548-8a12-4a62-a641-08bb003c55fa}) of ThingClass solarEdgeBattery + + + + + + + + + + + + Serial number + The name of the ParamType (ThingClass: solarEdgeBattery, Type: thing, ID: {efd99fe4-3a48-4208-829d-14e062015ee7}) +---------- +The name of the ParamType (ThingClass: sunspecStorage, Type: thing, ID: {5cf43b80-3e65-4f69-8bf5-91810da0ead4}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseMeter, Type: thing, ID: {97bbcc98-f4ec-4060-bac2-31b40720862c}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseMeter, Type: thing, ID: {fb7cc04b-91cb-4f3d-a444-201cca57934a}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseMeter, Type: thing, ID: {581fa623-0005-448f-a5b1-8db1043795a3}) +---------- +The name of the ParamType (ThingClass: sunspecThreePhaseInverter, Type: thing, ID: {ee169df5-475c-4dfb-870a-0785899b7dc1}) +---------- +The name of the ParamType (ThingClass: sunspecSplitPhaseInverter, Type: thing, ID: {8afdd256-0393-4610-9477-7c4541c92ffe}) +---------- +The name of the ParamType (ThingClass: sunspecSinglePhaseInverter, Type: thing, ID: {59b85218-f144-4ce8-95f7-19a1dc2e2a8d}) + + + + + Set charging rate + The name of the ActionType ({7f469bbc-64a5-4045-8d5f-9a1a85039851}) of ThingClass sunspecStorage + + + + + Set discharging rate + The name of the ActionType ({6068f030-acce-44a2-b95f-bd00dd5ca760}) of ThingClass sunspecStorage + + + + + Set grid charging + The name of the ActionType ({221a2ef6-0a92-4ff0-87fe-7bd920dbec0b}) of ThingClass sunspecStorage + + + + + + Slave ID + The name of the ParamType (ThingClass: solarEdgeConnection, Type: thing, ID: {b9cc58cf-ad15-447a-a94d-17b9204313bc}) +---------- +The name of the ParamType (ThingClass: sunspecConnection, Type: thing, ID: {953064e0-4675-4538-a9a2-fa22ce2f347c}) + + + + + + SolarEdge + The name of the ThingClass ({7a92bf65-b443-4491-a012-2bec35eb5bf0}) +---------- +The name of the vendor ({addd7dd8-13e1-405b-ba61-b2d82eaf0426}) + + + + + SolarEdge battery + The name of the ThingClass ({7900471a-da3f-4f03-98be-3e3d9c841546}) + + + + + State of health (SOH) + The name of the StateType ({24b501db-f301-422d-ad58-f16ca025f6ee}) of ThingClass solarEdgeBattery + + + + + Status + The name of the StateType ({da2b19c5-0f48-49d1-93f0-abdc0051407d}) of ThingClass sunspecStorage + + + + + SunSpec + The name of the vendor ({c143a7b4-a16c-4fff-86a3-9ffab3d6841d}) + + + + + SunSpec Generic + The name of the ThingClass ({f51853f3-8815-4cf3-b337-45cda1f3e6d5}) + + + + + SunSpec Storage + The name of the ThingClass ({9a643ba8-346c-4127-a2f8-956a7133d75e}) + + + + + SunSpec single phase inverter + The name of the ThingClass ({c5d5204e-3375-4b92-8128-fab77a671fed}) + + + + + SunSpec single phase meter + The name of the ThingClass ({7ffa43b8-b56f-4435-8509-980e9d81dfa8}) + + + + + SunSpec split phase inverter + The name of the ThingClass ({61b38f93-d331-42bf-b1ef-d3fb16ad1230}) + + + + + SunSpec split phase meter + The name of the ThingClass ({b8a18e45-5ff5-4f43-915f-04ee216c809d}) + + + + + SunSpec three phase inverter + The name of the ThingClass ({2e4122ea-96a5-415c-b5e2-7d6012265a83}) + + + + + SunSpec three phase meter + The name of the ThingClass ({68f822f9-ff30-4275-b229-39a3674fead7}) + + + + + Timout + The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {1a8895a0-c746-48af-9307-3a4636f24cc2}) + + + + + + + + Total AC current + The name of the StateType ({e85024af-5376-4ff1-813e-5a56990c11cc}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({26560dd8-6de4-445e-ba55-391d7241c370}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({9dbd8da7-dc22-4c3a-b941-47520fde705f}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({f02d1c99-9b43-45a6-8a06-2ed4d6e5d497}) of ThingClass sunspecSinglePhaseInverter + + + + + + + Total real energy exported + The name of the StateType ({01a69b2c-cc96-4925-9b1c-a2f204ef0837}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({8a63bd73-0546-4636-8da2-23238cc06fb2}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({ba275bdf-f418-4ef0-afbe-ac425c6f6783}) of ThingClass sunspecSinglePhaseMeter + + + + + + + Total real energy imported + The name of the StateType ({0a98b229-7625-4811-8bf8-ed22be2db3fb}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({51ffb2ae-3920-40df-8290-bbf5b6e1a68f}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({c51dc6cb-5c05-4078-b11a-26afb2f85541}) of ThingClass sunspecSinglePhaseMeter + + + + + + + Total real power + The name of the StateType ({47780b0a-68b7-46c3-bb98-79ec9a57fd69}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({ef4bc0f8-f516-49b7-aba8-d5f987485aca}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({93cf8c6a-2620-42ed-9070-e0726d7b1dbc}) of ThingClass sunspecSinglePhaseMeter + + + + + Update interval + The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {52da5222-9a94-47a2-9adc-004541d2f5ed}) + + + + + + + + + + + + Version + The name of the StateType ({db2e0304-4f0e-461f-8bab-c63fcb5deb1b}) of ThingClass solarEdgeBattery +---------- +The name of the StateType ({fca0c151-d8fa-4d6e-a5d7-b3bc965abc09}) of ThingClass sunspecStorage +---------- +The name of the StateType ({3d908dd2-4a93-4b62-8cbb-2fa9ef4d28fa}) of ThingClass sunspecThreePhaseMeter +---------- +The name of the StateType ({85c5087c-77b4-4ac8-bdff-020f6242bd57}) of ThingClass sunspecSplitPhaseMeter +---------- +The name of the StateType ({94adff6f-f672-4e4e-a72c-cd6cd7e8721a}) of ThingClass sunspecSinglePhaseMeter +---------- +The name of the StateType ({3b543b60-d8f6-4bc0-a79f-cb9cb20e4bb8}) of ThingClass sunspecThreePhaseInverter +---------- +The name of the StateType ({2986cac4-f4ac-4299-ac9e-48339e2a54ef}) of ThingClass sunspecSplitPhaseInverter +---------- +The name of the StateType ({f4439de3-a461-42d6-b4c9-aa35f696a420}) of ThingClass sunspecSinglePhaseInverter + + + + + sunspec + The name of the plugin SunSpec ({cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b}) + + + + From 93ecfb4ca1c1d426dd7764c66a82f3b56d5edd6b Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:16:06 +0200 Subject: [PATCH 05/24] specs: add acceptance criteria for Fronius SunSpec support AC-1..AC-4 are the four upstream anomalies of the generic sunspec vendor (setpoint range, read/write mismatch on the rate states, enableDischarging never reconciled, revert timer never written). AC-5..AC-15 are the Fronius-specific behaviour: manufacturer filter, byte order, connection parameters, polling floor, forbidden writes, the HEMS intent to register table, scale factor reread, readback verification and the model 160 block classification. Points the design contract leaves open are recorded as explicit working hypotheses (H-1..H-6) rather than blocking on them. --- specs/fronius-sunspec.md | 378 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 specs/fronius-sunspec.md diff --git a/specs/fronius-sunspec.md b/specs/fronius-sunspec.md new file mode 100644 index 0000000..1ac1494 --- /dev/null +++ b/specs/fronius-sunspec.md @@ -0,0 +1,378 @@ +# Spec — support Fronius du plugin SunSpec + +Critères d'acceptation numérotés, dérivés du contrat de conception Fronius +(document interne, non versionné ici) et du brief de lot 0. + +Deux natures de critères, à ne jamais mélanger dans un même commit : + +| Bloc | Nature | Commits | +|---|---|---| +| **AC-1 … AC-4** | correctifs d'anomalies présentes dans nymea 1.15.2 amont | message en anglais, aucune référence ETM / Fronius / PowerSync — ils ont vocation à être proposés en amont | +| **AC-5 … AC-15** | spécifique ETM (vendor `fronius`) | commits séparés, préfixe libre | + +Statut des critères : voir `## Tableau de statut` en fin de document. + +--- + +## Invariants applicables à toute l'implémentation + +Ces règles ne sont pas des critères testables un à un ; elles conditionnent +chaque AC ci-dessous. + +- **INV-1 — aucune adresse de registre en dur.** Toute lecture et toute écriture + passent par la découverte dynamique puis par les offsets du modèle. Le nombre + de blocs répétitifs du modèle 160 décale l'adresse absolue du modèle 124, et + sans batterie le modèle 124 est absent : une adresse absolue n'est jamais + valide durablement. Les adresses des documentations constructeur servent à + valider un dump, jamais à adresser. +- **INV-2 — `libnymea-sunspec` n'est jamais modifiée.** Tout vit dans + `sunspec/`. Voir `sunspec/VENDORED.md`. +- **INV-3 — une consigne écrite est relue.** Plusieurs onduleurs acceptent une + écriture puis l'ignorent sans code d'exception Modbus. +- **INV-4 — la régulation se ferme sur la puissance mesurée**, jamais sur la + consigne émise. +- **INV-5 — les scale factors ne sont pas statiques.** Ils changent avec le + firmware et en fonctionnement (auto-scale). Relire le SF concerné + immédiatement avant conversion et avant écriture. + +--- + +## Bloc 0 — correctifs upstream + +### AC-1 — plage des consignes de charge/décharge (anomalie A1) + +`chargingRate` et `dischargingRate` sont déclarés `minValue: 0, maxValue: 100` +dans `integrationpluginsunspec.json`. La plage réelle des points `InWRte` / +`OutWRte` du modèle 124 est **−100,00 … +100,00** (`int16`, SF −2). + +**Critère.** Dans `integrationpluginsunspec.json`, les deux states +`chargingRate` et `dischargingRate` de la thingClass `sunspecStorage` ont +`minValue == -100` et `maxValue == 100`. + +**Pourquoi c'est bloquant.** Avec la borne basse à 0, une consigne de charge +forcée (valeur négative, convention de signe du constructeur) est structurellement +inexprimable : nymea rejette le paramètre d'action avant qu'il n'atteigne le +plugin. + +--- + +### AC-2 — cohérence lecture/écriture des consignes (anomalie A2) + +`onStorageBlockUpdated()` alimente `chargingRate` depuis `wChaGra()` et +`dischargingRate` depuis `wDisChaGra()`. Ce sont les **gradients** +(`% WChaMax/s`), en lecture seule sur GEN24. Les actions, elles, écrivent dans +`InWRte` / `OutWRte` (`% WChaMax`). Le state relu n'est donc pas la grandeur +écrite : l'utilisateur écrit 50, relit tout autre chose. + +**Critère.** +1. `chargingRate` est alimenté depuis `inWRte()`. +2. `dischargingRate` est alimenté depuis `outWRte()`. +3. Les gradients restent exposés, mais sous des noms non ambigus : + `chargeGradient` ← `wChaGra()`, `dischargeGradient` ← `wDisChaGra()`, + en lecture seule. + +--- + +### AC-3 — réconciliation de `enableDischarging` (anomalie A3) + +`onStorageBlockUpdated()` réconcilie `enableCharging` depuis +`storCtlMod().testFlag(Storctl_modCharge)` mais ne fait rien pour +`enableDischarging`. Le state reste donc figé sur sa dernière valeur écrite, et +diverge silencieusement de l'onduleur dès que celui-ci change d'état seul. + +**Critère.** `enableDischarging` est alimenté depuis +`storCtlMod().testFlag(Storctl_modDiScharge)`, symétriquement à +`enableCharging`. + +--- + +### AC-4 — écriture de `InOutWRte_RvrtTms` (anomalie A4) + +Le setter `setInOutWRteRvrtTms()` existe dans `libnymea-sunspec` mais n'est +jamais appelé. `RvrtTms` est le délai au bout duquel l'onduleur abandonne la +consigne et reprend son comportement natif. À 0 — le défaut — une consigne reste +active indéfiniment, y compris si le HEMS s'arrête, perd le réseau ou plante. + +**Critère.** +1. Une valeur `revertTime` est configurable par thing (paramètre de la + thingClass `sunspecStorage`), en secondes. +2. Toute séquence de consigne (`InWRte`, `OutWRte`, `StorCtlMod`, `ChaGriSet`) + se termine par une écriture de `InOutWRte_RvrtTms` à cette valeur. +3. `RvrtTms` est écrit **en dernier** dans la séquence. +4. Défaut du vendor `sunspec` générique : **0** (comportement amont inchangé). + Défaut du vendor `fronius` : **300** (voir AC-10). + +**Plage.** 0 – 28800 s. + +--- + +## Bloc 1 — vendor `fronius`, structure + +### AC-5 — filtre fabricant à la découverte + +**Critère.** Pour `froniusConnectionThingClassId`, `discoverThings()` ne retient +que les résultats dont au moins un modèle Common porte un nom de fabricant +contenant `Fronius`, insensible à la casse (`hasManufacturer()` fait déjà un +`contains()` insensible à la casse). Les autres résultats sont ignorés, sans +créer de `ThingDescriptor`. + +Symétriquement, un GEN24 ne doit pas être proposé deux fois : le vendor +générique `sunspecConnection` continue de le proposer (comportement amont +inchangé) ; c'est l'utilisateur qui choisit la thingClass qu'il appaire. + +--- + +### AC-6 — ordre des octets + +**Critère.** Une connexion `fronius` utilise `ByteOrderLittleEndian`, c'est-à-dire +le défaut SunSpec, à la découverte comme à la connexion. + +Le `ByteOrderBigEndian` forcé du vendor `solarEdge` est une anomalie propre à +SolarEdge et ne doit pas être étendue. + +> **Inférence, pas fait mesuré.** L'ordre des octets du GEN24 n'est documenté +> nulle part dans la documentation constructeur consultée ; on suppose le défaut +> SunSpec. À confirmer au banc (phase 2) puis sur matériel (phase 3). + +--- + +### AC-7 — paramètres de la thingClass `froniusConnection` + +**Critère.** La thingClass `froniusConnection` existe, sous un bloc `vendors` +→ `fronius`, et porte exactement : + +| Paramètre | Défaut | Contrainte | +|---|---|---| +| `address` | — | comme `sunspecConnection` | +| `hostName` | — | comme `sunspecConnection` | +| `macAddress` | — | comme `sunspecConnection` | +| `port` | 502 | — | +| `slaveId` | **1** | fixe en TCP sur GEN24 : masqué ou en lecture seule | +| `meterSlaveId` | **200** | plage 0 – 247, `advanced`, **0 = pas de compteur sur cette connexion** | + +Aucun paramètre `endianness` n'est exposé (cf. AC-6 : la valeur est imposée). + +--- + +### AC-8 — plancher de cadence d'interrogation + +Le support constructeur recommande un intervalle d'interrogation supérieur à +**7 secondes** pour les applications tierces. En deçà, la surcharge de +communication peut faire passer l'onduleur hors ligne vis-à-vis du portail +constructeur ; le rétablissement exige une coupure AC+DC d'environ une minute +**sur site**. + +**Critère.** +1. Défauts du vendor `fronius` : `updateInterval = 10` s, `timeout = 2000` ms, + `numberOfRetries = 3`. +2. **Garde-fou dur** : toute valeur d'intervalle strictement inférieure à 7 s + configurée sur une connexion `fronius` est remontée à 7 s, avec un + `qCWarning` explicite. Ce n'est pas une valeur par défaut modifiable : c'est + un plancher dans le code. +3. Le plancher s'applique aussi en debug. +4. Le vendor `sunspec` générique et le vendor `solarEdge` ne sont pas affectés. + +--- + +### AC-9 — registres interdits en écriture + +Sur GEN24, ces écritures sont acceptées puis **ignorées sans code d'exception +Modbus** — l'échec est invisible côté HEMS : +`WChaMax`, `WChaGra`, `WDisChaGra`, `InOutWRte_WinTms`, `InOutWRte_RmpTms`, +`VAChaMax` (dont le scale factor n'est de surcroît pas supporté). + +**Critère.** Sur une connexion `fronius`, les setters correspondants +(`setWChaMax`, `setWChaGra`, `setWDisChaGra`, `setInOutWRteWinTms`, +`setInOutWRteRmpTms`, `setVAChaMax`) ne sont **jamais** appelés. Une tentative +est refusée avant émission, avec un `qCWarning` nommant le registre. + +--- + +### AC-10 — `revertTime` par défaut à 300 s sur `fronius` + +**Critère.** La thingClass `froniusConnection` impose `revertTime = 300` s aux +things `sunspecStorage` créés sous elle, là où le vendor générique laisse 0 +(AC-4.4). + +> **Hypothèse de travail — décision D4 non validée.** Le contrat recommande +> `RvrtTms = 300 s` à chaque consigne, avec rafraîchissement à chaque cycle +> d'optimisation, plutôt que le choix inverse (ne rien écrire) fait par evcc. +> Le raisonnement retenu : ce qui est acceptable pour un logiciel auto-hébergé +> ne l'est pas pour un produit installé sous la responsabilité d'ETM — une +> consigne persistante après un crash du HEMS immobilise la batterie du client. +> **Prérequis non levé :** la sémantique du timer est décrite dans le chapitre +> général du manuel et illustrée sur le modèle 123, pas sur le 124. Elle doit +> être vérifiée expérimentalement sur le modèle 124 avant d'être figée. Si la +> vérification échoue : replier sur `RvrtTms = 0` + watchdog applicatif côté +> HEMS. + +--- + +## Bloc 2 — chaîne de pilotage batterie + +### AC-11 — correspondance intention HEMS → registres + +Convention de signe du constructeur : **négatif = charge, positif = décharge**. + +**Critère.** Les six modes produisent exactement cette séquence de registres : + +| Mode | `ChaGriSet` | `StorCtl_Mod` | `InWRte` | `OutWRte` | `RvrtTms` | +|---|---|---|---|---|---| +| `NORMAL` | — | 0 | — | 100 | 300 | +| `NO_DISCHARGE` | — | 2 | — | 0 | 300 | +| `NO_CHARGE` | — | 1 | 0 | — | 300 | +| `FROZEN` | — | 3 | 0 | 0 | 300 | +| `FORCE_CHARGE(p %)` | 1 | 2 | — | **−p** | 300 | +| `LIMIT(c %, d %)` | — | 3 | c | d | 300 | + +`—` = registre non écrit pour ce mode. + +**Ordre d'écriture imposé :** `ChaGriSet` → `StorCtl_Mod` → `InWRte` / `OutWRte` +→ `RvrtTms` en dernier. + +`StorCtl_Mod` est un bitfield : bit 0 = CHARGE, bit 1 = DISCHARGE. +`ChaGriSet` : 0 = PV, 1 = GRID. + +> Les exemples du manuel constructeur affichent des fenêtres de puissance dont +> les signes sont incohérents avec la convention annoncée. Les **valeurs de +> registres** de ces exemples sont correctes, les fenêtres annotées ne le sont +> pas : la table ci-dessus est construite sur les valeurs de registres. + +--- + +### AC-12 — relecture du scale factor avant écriture + +**Critère.** `InOutWRte_SF` est relu immédiatement avant chaque écriture de +`InWRte` / `OutWRte`. Une valeur de SF mise en cache lors d'un cycle +d'interrogation antérieur n'est pas réutilisée. + +Motif (INV-5) : le constructeur avertit que les SF changent avec le firmware +**et en fonctionnement**. Un SF auto-scalé entre la lecture et l'écriture +produit une consigne d'un facteur 10 à côté. + +--- + +### AC-13 — vérification par relecture et state `controlHealthy` + +**Critère.** +1. Après chaque séquence de consigne, `StorCtl_Mod`, `InWRte`, `OutWRte` et + `ChaGriSet` sont relus et comparés aux valeurs écrites. +2. En cas de divergence : `qCWarning` explicite nommant le registre, la valeur + écrite et la valeur relue. +3. Un state `controlHealthy` (bool) passe à `false` en cas de divergence, à + `true` après une séquence vérifiée conforme. + +C'est la seule parade aux échecs silencieux du §5 du contrat (pilotage Modbus +non autorisé, charge réseau non autorisée, restriction d'IP, assistant de +tarification qui écrase les consignes). + +--- + +## Bloc 3 — modèle 160 (MPPT et blocs batterie) + +### AC-14 — identification des blocs par `IDStr`, jamais par index + +Les blocs répétitifs du modèle 160 portent les chaînes `MPPT 1`, `MPPT 2`, +`StCha 3`, `StDisCha 4`. Sur un onduleur à 3 ou 4 MPPT, les blocs batterie ne +sont **pas** en position 3 et 4. + +**Critère.** +1. Le classement d'un bloc en « string PV » ou « batterie » se fait sur + `inputIdSting()`, jamais sur l'index du bloc. +2. Un bloc dont `IDStr` commence par `MPPT` est un string PV. +3. Un bloc dont `IDStr` commence par `StCha` est le bloc batterie en charge ; + `StDisCha`, le bloc batterie en décharge. +4. Un `IDStr` inconnu n'est pas classé et ne contribue à aucun agrégat ; il est + signalé par un `qCWarning`. + +Le préfixe est comparé de façon insensible à la casse et après suppression des +espaces de bord. **Attention à l'ordre du test** : `StDisCha` contient +`StCha` comme sous-chaîne uniquement si on teste par `contains()` — le test doit +donc porter sur le préfixe, et `StDisCha` doit être testé avant `StCha` si une +implémentation par `startsWith` en cascade est retenue. + +--- + +### AC-15 — agrégats de puissance issus du modèle 160 + +`DCW` est un `uint16`, donc toujours positif : le signe est porté par le bloc +actif, le bloc inactif étant à zéro. + +**Critère.** +1. Puissance PV = Σ `DCW` des blocs classés « string PV ». +2. Puissance batterie = `DCW[StDisCha]` − `DCW[StCha]`, **positif = décharge**. +3. `DCW_SF` est partagé par tous les modules et auto-scalé : il est relu avant + conversion (INV-5). +4. **Aucun calcul de production PV par différence AC/DC ± batterie.** Le GEN24 + expose la puissance PV réelle string par string ; répliquer le contournement + du vendor `solarEdge` — qui existe parce que cet onduleur n'expose pas le + modèle 160 — serait une régression. + +--- + +## Hypothèses de travail — points non tranchés + +Chacun de ces points est traité selon l'hypothèse indiquée, faute de +confirmation humaine au moment de l'implémentation. + +| # | Point | Statut au contrat | Hypothèse retenue | +|---|---|---|---| +| H-1 | Architecture multi-unit-ID (D1) | ⬜ en attente de validation | **Option A′** : un thing `froniusConnection` portant deux `SunSpecConnection` internes (onduleur `slaveId`, compteur `meterSlaveId`). C'est l'option recommandée par le contrat : UX correcte, aucune modification de `libnymea-sunspec`, rebasabilité préservée. | +| H-2 | Watchdog batterie (D4) | ⬜ à valider | **`RvrtTms = 300 s`** à chaque consigne (AC-10). Prérequis non levé : sémantique du timer à vérifier sur le modèle 124, pas seulement sur le 123. | +| H-3 | Ordre des octets du GEN24 | inférence explicite du brief | Défaut SunSpec, little-endian (AC-6). Non documenté par le constructeur. | +| H-4 | Deux connexions TCP simultanées vers le même onduleur | inférence explicite du brief | Non supposé résolu. Le constructeur recommande des requêtes séquentielles ; deux boucles d'interrogation parallèles violeraient potentiellement cette recommandation. **À mesurer au banc**, pas à supposer. En attendant, sérialiser. | +| H-5 | Assistant de tarification écrasant les consignes | « à confirmer », absent de la doc constructeur | Constat empirique tiers. Traité en message de diagnostic (AC-13.2), pas en logique de pilotage. | +| H-6 | Ordre des phases du chantier | contrat §8 : la phase 2 ne démarre pas avant que la phase 1 (simulateur) soit verte | **Précondition non vérifiée ici.** Le simulateur `hems-sim` n'a pas été exécuté. Les AC ci-dessous sont validés par tests unitaires hors ligne, ce qui ne remplace pas une validation contre un simulateur reproduisant la sémantique du constructeur. | + +--- + +## Non vérifiable par les tests unitaires de ce dépôt + +À porter en phase 2 (banc / simulateur) puis en phase 3 (matériel) : + +- l'ordre des octets réel du GEN24 (H-3) ; +- le comportement en double connexion TCP (H-4) ; +- l'expiration effective de `RvrtTms` sur le modèle 124 (H-2) ; +- le refus d'écriture par exception Modbus quand le pilotage n'est pas autorisé + côté onduleur ; +- l'auto-scale effectif des scale factors en fonctionnement (INV-5) ; +- le fait que les registres de AC-9 soient bien ignorés silencieusement ; +- le comportement des blocs batterie du modèle 160 selon la version de firmware + (avant/après 1.31, `DCV` et `DCWH` reportés en continu ou zérotés à l'arrêt). + +--- + +## Hors périmètre + +- modèles 7xx ; +- Modbus RTU ; +- onduleurs Verto / Tauro (le mapping MPPT diffère — c'est le motif d'AC-14, + mais ils ne sont pas testés) ; +- toute modification de `libnymea-sunspec` (INV-2) ; +- tout mécanisme de compensation PV à la SolarEdge (AC-15.4) ; +- anomalies A5 (modèle 160 absent du `switch` de `processDiscoveryResult()`), + A6 (`MinRsvPct` non exposé) et A7 (`.toInt()` sur des paramètres destinés à + des setters `float`) : réelles et documentées au contrat, mais hors des quatre + correctifs demandés pour ce lot. + +--- + +## Tableau de statut + +| AC | Objet | Test | Statut | +|---|---|---|---| +| AC-1 | plage −100…+100 | `test_AC_1_*` | à renseigner | +| AC-2 | lecture `inWRte`/`outWRte` | `test_AC_2_*` | à renseigner | +| AC-3 | `enableDischarging` relu | `test_AC_3_*` | à renseigner | +| AC-4 | `RvrtTms` écrit en dernier | `test_AC_4_*` | à renseigner | +| AC-5 | filtre fabricant | `test_AC_5_*` | à renseigner | +| AC-6 | little-endian | `test_AC_6_*` | à renseigner | +| AC-7 | paramètres `froniusConnection` | `test_AC_7_*` | à renseigner | +| AC-8 | plancher 7 s | `test_AC_8_*` | à renseigner | +| AC-9 | registres interdits | `test_AC_9_*` | à renseigner | +| AC-10 | `revertTime` 300 s | `test_AC_10_*` | à renseigner | +| AC-11 | table mode → registres | `test_AC_11_*` | à renseigner | +| AC-12 | relecture du SF | `test_AC_12_*` | à renseigner | +| AC-13 | relecture + `controlHealthy` | `test_AC_13_*` | à renseigner | +| AC-14 | classement par `IDStr` | `test_AC_14_*` | à renseigner | +| AC-15 | agrégats modèle 160 | `test_AC_15_*` | à renseigner | From d87a132f23c4917fe38ecdddae26aa8fb4d4d7ac Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:16:12 +0200 Subject: [PATCH 06/24] sunspec: add failing tests for every acceptance criterion One test per AC of specs/fronius-sunspec.md, named test_AC__. Definition criteria (value ranges, thing classes, defaults) are checked by re-reading integrationpluginsunspec.json. Behaviour criteria run either on a model 124 register dump fed to the real libnymea-sunspec model, or on the pure logic units the plugin is about to grow. All red at this point: sunspecstoragelogic and froniusrules do not exist yet, so the suite does not compile. That is the expected starting state. --- sunspec/tests/tests.pro | 30 ++ sunspec/tests/tst_sunspec.cpp | 787 ++++++++++++++++++++++++++++++++++ 2 files changed, 817 insertions(+) create mode 100644 sunspec/tests/tests.pro create mode 100644 sunspec/tests/tst_sunspec.cpp diff --git a/sunspec/tests/tests.pro b/sunspec/tests/tests.pro new file mode 100644 index 0000000..a8981ef --- /dev/null +++ b/sunspec/tests/tests.pro @@ -0,0 +1,30 @@ +include(../../sunspec.pri) + +QT += testlib +QT -= gui +CONFIG += console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = tst_sunspec + +# Les tests pilotent la logique pure du plugin. Ils ne chargent pas le plugin +# nymea lui-meme : cela demanderait un ThingManager, un PluginTimer et un +# NetworkDeviceDiscovery, c'est-a-dire un nymead complet. Ce qui est teste ici, +# c'est ce que le plugin *decide* ; ce qu'il emet reellement sur le bus releve +# du banc (voir "Non verifiable par les tests unitaires" dans +# specs/fronius-sunspec.md). + +INCLUDEPATH += .. + +# Chemin du JSON de definition du plugin, pour les criteres qui portent sur les +# thingClasses et leurs plages de valeurs (AC-1, AC-7, AC-8, AC-10). +DEFINES += PLUGIN_JSON_PATH=\\\"$$PWD/../integrationpluginsunspec.json\\\" + +SOURCES += \ + tst_sunspec.cpp \ + ../sunspecstoragelogic.cpp \ + ../froniusrules.cpp + +HEADERS += \ + ../sunspecstoragelogic.h \ + ../froniusrules.h diff --git a/sunspec/tests/tst_sunspec.cpp b/sunspec/tests/tst_sunspec.cpp new file mode 100644 index 0000000..fd218c8 --- /dev/null +++ b/sunspec/tests/tst_sunspec.cpp @@ -0,0 +1,787 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* Tests des criteres d'acceptation de specs/fronius-sunspec.md. + * + * Chaque test porte le numero de l'AC qu'il valide : test_AC__. + * + * Deux mecanismes : + * + * - les criteres qui portent sur la *definition* du plugin (plages de valeurs, + * thingClasses, valeurs par defaut) sont verifies en relisant + * integrationpluginsunspec.json ; + * - les criteres qui portent sur le *comportement* sont verifies soit sur un + * dump de registres passe au vrai modele de libnymea-sunspec, soit sur les + * fonctions pures de sunspecstoragelogic / froniusrules. + * + * Ce qui n'est pas verifiable ici (trames Modbus reellement emises, expiration + * du timer, auto-scale des SF en fonctionnement) est liste explicitement dans + * specs/fronius-sunspec.md. + */ + +#include +#include +#include +#include +#include +#include + +#include + +#include "sunspecstoragelogic.h" +#include "froniusrules.h" + +using namespace SunSpecStorageLogic; + +/* Donne acces aux entrailles du modele pour lui injecter un dump de registres + * sans connexion Modbus. Reproduit ce que fait SunSpecModel::handleNewBlockData() + * apres une lecture reelle : remplir les data points depuis le bloc, puis + * appeler processBlockData(). */ +class StorageModelFixture : public SunSpecStorageModel +{ +public: + explicit StorageModelFixture(const QVector &blockData) + : SunSpecStorageModel(nullptr, 40000, 24, SunSpecDataPoint::ByteOrderLittleEndian) + { + m_blockData = blockData; + foreach (const QString &name, m_dataPoints.keys()) { + m_dataPoints[name].setRawData(m_blockData.mid(m_dataPoints[name].addressOffset(), + m_dataPoints[name].size())); + } + processBlockData(); + } +}; + +/* Dump du modele 124, L = 24 : ID + L + 24 registres. Les offsets sont ceux du + * modele genere (ID a 0), pas des adresses absolues : aucune adresse en dur + * n'est introduite ici (INV-1). */ +struct StorageBlock +{ + quint16 wChaMax = 10000; + qint16 wChaGra = 300; // gradient, % WChaMax/s + qint16 wDisChaGra = 400; // gradient, % WChaMax/s + quint16 storCtlMod = 0; // bitfield : bit 0 charge, bit 1 decharge + quint16 vaChaMax = 0; + quint16 minRsvPct = 500; + quint16 chaState = 5000; // 50,00 % avec SF -2 + quint16 storAval = 0xFFFF; // non supporte -> sentinelle + quint16 inBatV = 0xFFFF; // non supporte -> sentinelle + quint16 chaSt = 4; // CHARGING + qint16 outWRte = 1000; // 10,00 % avec SF -2 + qint16 inWRte = -2500; // -25,00 % avec SF -2 + quint16 winTms = 0; + quint16 rvrtTms = 0; + quint16 rmpTms = 0; + quint16 chaGriSet = 0; // 0 = PV, 1 = GRID + qint16 wChaMaxSf = 0; + qint16 wChaDisChaGraSf = -2; + qint16 vaChaMaxSf = 0; + qint16 minRsvPctSf = -2; + qint16 chaStateSf = -2; + qint16 storAvalSf = 0; + qint16 inBatVSf = 0; + qint16 inOutWRteSf = -2; + + QVector toRegisters() const + { + QVector r(26, 0); + r[0] = 124; // ID + r[1] = 24; // L + r[2] = wChaMax; + r[3] = static_cast(wChaGra); + r[4] = static_cast(wDisChaGra); + r[5] = storCtlMod; + r[6] = vaChaMax; + r[7] = minRsvPct; + r[8] = chaState; + r[9] = storAval; + r[10] = inBatV; + r[11] = chaSt; + r[12] = static_cast(outWRte); + r[13] = static_cast(inWRte); + r[14] = winTms; + r[15] = rvrtTms; + r[16] = rmpTms; + r[17] = chaGriSet; + r[18] = static_cast(wChaMaxSf); + r[19] = static_cast(wChaDisChaGraSf); + r[20] = static_cast(vaChaMaxSf); + r[21] = static_cast(minRsvPctSf); + r[22] = static_cast(chaStateSf); + r[23] = static_cast(storAvalSf); + r[24] = static_cast(inBatVSf); + r[25] = static_cast(inOutWRteSf); + return r; + } +}; + +class SunSpecFroniusTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + // Bloc 0 - correctifs upstream + void test_AC_1_charging_rate_range_allows_forced_charge(); + void test_AC_1_discharging_rate_range_allows_negative(); + void test_AC_2_charging_rate_is_read_from_in_w_rte(); + void test_AC_2_discharging_rate_is_read_from_out_w_rte(); + void test_AC_2_gradients_exposed_under_distinct_names(); + void test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod(); + void test_AC_3_enable_charging_still_reconciled(); + void test_AC_4_setpoint_plan_writes_revert_time_last(); + void test_AC_4_revert_time_is_written_even_for_a_single_setpoint(); + void test_AC_4_generic_vendor_defaults_revert_time_to_zero(); + + // Bloc 1 - vendor fronius + void test_AC_5_manufacturer_filter_matches_fronius_case_insensitively(); + void test_AC_5_manufacturer_filter_rejects_other_manufacturers(); + void test_AC_6_fronius_uses_sunspec_default_byte_order(); + void test_AC_7_fronius_connection_declares_expected_parameters(); + void test_AC_7_fronius_connection_does_not_expose_endianness(); + void test_AC_8_update_interval_floor_is_seven_seconds(); + void test_AC_8_update_interval_above_floor_is_untouched(); + void test_AC_8_fronius_polling_defaults(); + void test_AC_9_forbidden_registers_are_refused(); + void test_AC_9_allowed_registers_are_not_refused(); + void test_AC_10_fronius_revert_time_default_is_300_seconds(); + + // Bloc 2 - pilotage batterie + void test_AC_11_mode_produces_contract_registers_data(); + void test_AC_11_mode_produces_contract_registers(); + void test_AC_11_write_order_follows_contract(); + void test_AC_12_plan_rereads_scale_factor_before_writing(); + void test_AC_13_plan_verifies_written_registers_by_readback(); + + // Bloc 3 - modele 160 + void test_AC_14_blocks_are_classified_by_id_string(); + void test_AC_14_st_dis_cha_is_not_confused_with_st_cha(); + void test_AC_14_unknown_id_string_is_not_classified(); + void test_AC_15_pv_power_sums_mppt_blocks_only(); + void test_AC_15_battery_power_is_positive_when_discharging(); + void test_AC_15_battery_power_is_negative_when_charging(); + +private: + QJsonObject m_pluginJson; + + QJsonObject thingClassByName(const QString &name) const; + QJsonObject stateByName(const QJsonObject &thingClass, const QString &name) const; + QJsonObject paramByName(const QJsonObject &thingClass, const QString &name) const; + static QJsonObject findByName(const QJsonArray &array, const QString &name); +}; + +void SunSpecFroniusTest::initTestCase() +{ + QFile file(PLUGIN_JSON_PATH); + QVERIFY2(file.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH))); + + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); + QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString())); + m_pluginJson = document.object(); + QVERIFY(!m_pluginJson.isEmpty()); +} + +QJsonObject SunSpecFroniusTest::findByName(const QJsonArray &array, const QString &name) +{ + foreach (const QJsonValue &value, array) { + if (value.toObject().value("name").toString() == name) + return value.toObject(); + } + return QJsonObject(); +} + +/* Cherche une thingClass a la racine puis dans chaque vendor. */ +QJsonObject SunSpecFroniusTest::thingClassByName(const QString &name) const +{ + const QJsonObject direct = findByName(m_pluginJson.value("thingClasses").toArray(), name); + if (!direct.isEmpty()) + return direct; + + foreach (const QJsonValue &vendor, m_pluginJson.value("vendors").toArray()) { + const QJsonObject found = findByName(vendor.toObject().value("thingClasses").toArray(), name); + if (!found.isEmpty()) + return found; + } + return QJsonObject(); +} + +QJsonObject SunSpecFroniusTest::stateByName(const QJsonObject &thingClass, const QString &name) const +{ + return findByName(thingClass.value("stateTypes").toArray(), name); +} + +QJsonObject SunSpecFroniusTest::paramByName(const QJsonObject &thingClass, const QString &name) const +{ + return findByName(thingClass.value("paramTypes").toArray(), name); +} + +// --------------------------------------------------------------------------- +// AC-1 - plage des consignes +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_1_charging_rate_range_allows_forced_charge() +{ + const QJsonObject storage = thingClassByName("sunspecStorage"); + QVERIFY(!storage.isEmpty()); + + const QJsonObject state = stateByName(storage, "chargingRate"); + QVERIFY(!state.isEmpty()); + + // La plage reelle de InWRte est -100,00 .. +100,00. Bornee a 0, une consigne + // de charge forcee est rejetee par nymea avant d'atteindre le plugin. + QCOMPARE(state.value("minValue").toDouble(), -100.0); + QCOMPARE(state.value("maxValue").toDouble(), 100.0); +} + +void SunSpecFroniusTest::test_AC_1_discharging_rate_range_allows_negative() +{ + const QJsonObject storage = thingClassByName("sunspecStorage"); + const QJsonObject state = stateByName(storage, "dischargingRate"); + QVERIFY(!state.isEmpty()); + + QCOMPARE(state.value("minValue").toDouble(), -100.0); + QCOMPARE(state.value("maxValue").toDouble(), 100.0); +} + +// --------------------------------------------------------------------------- +// AC-2 - coherence lecture/ecriture +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_2_charging_rate_is_read_from_in_w_rte() +{ + StorageBlock block; + block.inWRte = -2500; // -25,00 % avec SF -2 + block.wChaGra = 300; // gradient : valeur volontairement differente + StorageModelFixture model(block.toRegisters()); + + const StorageStates states = readStates(&model); + + // La grandeur ecrite par l'action chargingRate est InWRte. Le state relu + // doit etre la meme grandeur, pas le gradient WChaGra. + QCOMPARE(states.chargingRate, -25.0); + QVERIFY(states.chargingRate != states.chargeGradient); +} + +void SunSpecFroniusTest::test_AC_2_discharging_rate_is_read_from_out_w_rte() +{ + StorageBlock block; + block.outWRte = 1000; // 10,00 % + block.wDisChaGra = 400; // gradient different + StorageModelFixture model(block.toRegisters()); + + const StorageStates states = readStates(&model); + + QCOMPARE(states.dischargingRate, 10.0); + QVERIFY(states.dischargingRate != states.dischargeGradient); +} + +void SunSpecFroniusTest::test_AC_2_gradients_exposed_under_distinct_names() +{ + StorageBlock block; + block.wChaGra = 300; // 3,00 % WChaMax/s avec SF -2 + block.wDisChaGra = 400; // 4,00 % WChaMax/s + StorageModelFixture model(block.toRegisters()); + + const StorageStates states = readStates(&model); + + QCOMPARE(states.chargeGradient, 3.0); + QCOMPARE(states.dischargeGradient, 4.0); + + // Les gradients restent exposes, mais sous des noms qui ne se confondent + // pas avec les consignes, et en lecture seule. + const QJsonObject storage = thingClassByName("sunspecStorage"); + const QJsonObject chargeGradient = stateByName(storage, "chargeGradient"); + const QJsonObject dischargeGradient = stateByName(storage, "dischargeGradient"); + QVERIFY(!chargeGradient.isEmpty()); + QVERIFY(!dischargeGradient.isEmpty()); + QVERIFY(!chargeGradient.value("writable").toBool()); + QVERIFY(!dischargeGradient.value("writable").toBool()); +} + +// --------------------------------------------------------------------------- +// AC-3 - reconciliation de enableDischarging +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_3_enable_discharging_reconciled_from_stor_ctl_mod() +{ + StorageBlock discharging; + discharging.storCtlMod = 0x2; // bit 1 = DISCHARGE + StorageModelFixture dischargingModel(discharging.toRegisters()); + QCOMPARE(readStates(&dischargingModel).enableDischarging, true); + + StorageBlock notDischarging; + notDischarging.storCtlMod = 0x1; // seulement CHARGE + StorageModelFixture notDischargingModel(notDischarging.toRegisters()); + QCOMPARE(readStates(¬DischargingModel).enableDischarging, false); +} + +void SunSpecFroniusTest::test_AC_3_enable_charging_still_reconciled() +{ + // Garde-fou : la correction de enableDischarging ne doit pas casser + // enableCharging, qui etait deja correct en amont. + StorageBlock block; + block.storCtlMod = 0x1; + StorageModelFixture model(block.toRegisters()); + + const StorageStates states = readStates(&model); + QCOMPARE(states.enableCharging, true); + QCOMPARE(states.enableDischarging, false); +} + +// --------------------------------------------------------------------------- +// AC-4 - ecriture de InOutWRte_RvrtTms +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_4_setpoint_plan_writes_revert_time_last() +{ + const SetpointPlan plan = planSetpoint({{RegisterInWRte, 20.0}, + {RegisterStorCtlMod, 3.0}}, 300); + + QVERIFY(!plan.writes.isEmpty()); + QCOMPARE(plan.writes.last().reg, RegisterRvrtTms); + QCOMPARE(plan.writes.last().value, 300.0); + + // RvrtTms n'apparait qu'une fois, et en derniere position seulement. + int revertCount = 0; + foreach (const Write &write, plan.writes) { + if (write.reg == RegisterRvrtTms) + revertCount++; + } + QCOMPARE(revertCount, 1); +} + +void SunSpecFroniusTest::test_AC_4_revert_time_is_written_even_for_a_single_setpoint() +{ + // Une consigne isolee doit elle aussi armer le timer : c'est justement le + // cas ou une valeur persistante apres un arret du HEMS immobilise la + // batterie. + const SetpointPlan plan = planSetpoint({{RegisterOutWRte, 0.0}}, 300); + + QCOMPARE(plan.writes.size(), 2); + QCOMPARE(plan.writes.at(0).reg, RegisterOutWRte); + QCOMPARE(plan.writes.at(1).reg, RegisterRvrtTms); +} + +void SunSpecFroniusTest::test_AC_4_generic_vendor_defaults_revert_time_to_zero() +{ + // Le vendor generique ne change pas de comportement par defaut : c'est un + // correctif proposable en amont, pas un changement de produit. + const QJsonObject storage = thingClassByName("sunspecStorage"); + const QJsonObject param = paramByName(storage, "revertTime"); + QVERIFY2(!param.isEmpty(), "sunspecStorage doit exposer un parametre revertTime"); + + QCOMPARE(param.value("defaultValue").toInt(), 0); + QCOMPARE(param.value("minValue").toInt(), 0); + QCOMPARE(param.value("maxValue").toInt(), 28800); +} + +// --------------------------------------------------------------------------- +// AC-5 - filtre fabricant +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_5_manufacturer_filter_matches_fronius_case_insensitively() +{ + QVERIFY(FroniusRules::matchesManufacturer({"Fronius"})); + QVERIFY(FroniusRules::matchesManufacturer({"fronius"})); + QVERIFY(FroniusRules::matchesManufacturer({"FRONIUS International GmbH"})); + // Le fabricant peut n'apparaitre que sur l'un des modeles Common. + QVERIFY(FroniusRules::matchesManufacturer({"", "Fronius"})); +} + +void SunSpecFroniusTest::test_AC_5_manufacturer_filter_rejects_other_manufacturers() +{ + QVERIFY(!FroniusRules::matchesManufacturer({"SolarEdge"})); + QVERIFY(!FroniusRules::matchesManufacturer({"Kostal"})); + QVERIFY(!FroniusRules::matchesManufacturer({})); + QVERIFY(!FroniusRules::matchesManufacturer({""})); +} + +// --------------------------------------------------------------------------- +// AC-6 - ordre des octets +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_6_fronius_uses_sunspec_default_byte_order() +{ + // INFERE : non documente par le constructeur, on suppose le defaut SunSpec. + // Le ByteOrderBigEndian force du vendor solarEdge est une anomalie propre a + // SolarEdge et ne doit pas etre etendue. + QCOMPARE(FroniusRules::byteOrder(), SunSpecDataPoint::ByteOrderLittleEndian); +} + +// --------------------------------------------------------------------------- +// AC-7 - parametres de froniusConnection +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_7_fronius_connection_declares_expected_parameters() +{ + const QJsonObject connection = thingClassByName("froniusConnection"); + QVERIFY2(!connection.isEmpty(), "la thingClass froniusConnection doit exister"); + + foreach (const QString &name, QStringList({"address", "hostName", "macAddress", "port", "slaveId", "meterSlaveId"})) { + QVERIFY2(!paramByName(connection, name).isEmpty(), qPrintable("parametre manquant : " + name)); + } + + QCOMPARE(paramByName(connection, "port").value("defaultValue").toInt(), 502); + + // Fixe en TCP sur GEN24. + QCOMPARE(paramByName(connection, "slaveId").value("defaultValue").toInt(), 1); + + // 0 = pas de compteur sur cette connexion. + const QJsonObject meterSlaveId = paramByName(connection, "meterSlaveId"); + QCOMPARE(meterSlaveId.value("defaultValue").toInt(), 200); + QCOMPARE(meterSlaveId.value("minValue").toInt(), 0); + QCOMPARE(meterSlaveId.value("maxValue").toInt(), 247); +} + +void SunSpecFroniusTest::test_AC_7_fronius_connection_does_not_expose_endianness() +{ + const QJsonObject connection = thingClassByName("froniusConnection"); + QVERIFY(paramByName(connection, "endianness").isEmpty()); +} + +// --------------------------------------------------------------------------- +// AC-8 - plancher de cadence d'interrogation +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_8_update_interval_floor_is_seven_seconds() +{ + // Descendre sous le plancher peut faire passer l'onduleur du client hors + // ligne, avec retablissement seulement par coupure AC+DC sur site. C'est un + // garde-fou dans le code, pas une valeur par defaut modifiable. + QCOMPARE(FroniusRules::clampUpdateInterval(1), 7); + QCOMPARE(FroniusRules::clampUpdateInterval(6), 7); + QCOMPARE(FroniusRules::clampUpdateInterval(0), 7); + QCOMPARE(FroniusRules::clampUpdateInterval(-5), 7); +} + +void SunSpecFroniusTest::test_AC_8_update_interval_above_floor_is_untouched() +{ + QCOMPARE(FroniusRules::clampUpdateInterval(7), 7); + QCOMPARE(FroniusRules::clampUpdateInterval(10), 10); + QCOMPARE(FroniusRules::clampUpdateInterval(60), 60); +} + +void SunSpecFroniusTest::test_AC_8_fronius_polling_defaults() +{ + const QJsonObject connection = thingClassByName("froniusConnection"); + QVERIFY(!connection.isEmpty()); + + QCOMPARE(FroniusRules::DefaultUpdateIntervalSeconds, 10); + QCOMPARE(FroniusRules::DefaultTimeoutMilliSeconds, 2000); + QCOMPARE(FroniusRules::DefaultNumberOfRetries, 3); + + const QJsonObject updateInterval = paramByName(connection, "updateInterval"); + if (!updateInterval.isEmpty()) { + QCOMPARE(updateInterval.value("defaultValue").toInt(), 10); + QCOMPARE(updateInterval.value("minValue").toInt(), 7); + } +} + +// --------------------------------------------------------------------------- +// AC-9 - registres interdits en ecriture +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_9_forbidden_registers_are_refused() +{ + // Ces ecritures sont acceptees puis ignorees sans code d'exception Modbus : + // l'echec est invisible cote HEMS. On refuse donc avant emission. + foreach (const QString &name, QStringList({"WChaMax", "WChaGra", "WDisChaGra", + "InOutWRte_WinTms", "InOutWRte_RmpTms", + "VAChaMax"})) { + QVERIFY2(FroniusRules::isWriteForbidden(name), qPrintable("doit etre refuse : " + name)); + } +} + +void SunSpecFroniusTest::test_AC_9_allowed_registers_are_not_refused() +{ + foreach (const QString &name, QStringList({"StorCtl_Mod", "InWRte", "OutWRte", + "InOutWRte_RvrtTms", "ChaGriSet", "MinRsvPct"})) { + QVERIFY2(!FroniusRules::isWriteForbidden(name), qPrintable("ne doit pas etre refuse : " + name)); + } +} + +// --------------------------------------------------------------------------- +// AC-10 - revertTime a 300 s sur fronius +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_10_fronius_revert_time_default_is_300_seconds() +{ + // HYPOTHESE DE TRAVAIL (D4 non validee) : une consigne persistante apres un + // crash du HEMS immobilise la batterie du client. On arme donc le timer. + QCOMPARE(FroniusRules::DefaultRevertTimeSeconds, 300); + + // Plage du registre. + QVERIFY(FroniusRules::DefaultRevertTimeSeconds > 0); + QVERIFY(FroniusRules::DefaultRevertTimeSeconds <= 28800); +} + +// --------------------------------------------------------------------------- +// AC-11 - table intention HEMS -> registres +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_11_mode_produces_contract_registers_data() +{ + QTest::addColumn("mode"); + QTest::addColumn("chargeLimit"); + QTest::addColumn("dischargeLimit"); + QTest::addColumn("chaGriSet"); // -1 = registre non ecrit + QTest::addColumn("storCtlMod"); + QTest::addColumn("inWRte"); // NaN = registre non ecrit + QTest::addColumn("outWRte"); + + const double none = qQNaN(); + + QTest::newRow("NORMAL") + << int(FroniusRules::BatteryModeNormal) << 0.0 << 0.0 + << -1 << 0 << none << 100.0; + + QTest::newRow("NO_DISCHARGE") + << int(FroniusRules::BatteryModeNoDischarge) << 0.0 << 0.0 + << -1 << 2 << none << 0.0; + + QTest::newRow("NO_CHARGE") + << int(FroniusRules::BatteryModeNoCharge) << 0.0 << 0.0 + << -1 << 1 << 0.0 << none; + + QTest::newRow("FROZEN") + << int(FroniusRules::BatteryModeFrozen) << 0.0 << 0.0 + << -1 << 3 << 0.0 << 0.0; + + // Convention de signe du constructeur : negatif = charge. + QTest::newRow("FORCE_CHARGE_60") + << int(FroniusRules::BatteryModeForceCharge) << 60.0 << 0.0 + << 1 << 2 << none << -60.0; + + QTest::newRow("LIMIT_40_70") + << int(FroniusRules::BatteryModeLimit) << 40.0 << 70.0 + << -1 << 3 << 40.0 << 70.0; +} + +void SunSpecFroniusTest::test_AC_11_mode_produces_contract_registers() +{ + QFETCH(int, mode); + QFETCH(double, chargeLimit); + QFETCH(double, dischargeLimit); + QFETCH(int, chaGriSet); + QFETCH(int, storCtlMod); + QFETCH(double, inWRte); + QFETCH(double, outWRte); + + const SetpointPlan plan = FroniusRules::planBatteryMode( + static_cast(mode), chargeLimit, dischargeLimit, + FroniusRules::DefaultRevertTimeSeconds); + + auto valueOf = [&plan](Register reg, bool *found) { + *found = false; + double value = 0; + foreach (const Write &write, plan.writes) { + if (write.reg == reg) { + *found = true; + value = write.value; + } + } + return value; + }; + + bool found = false; + double value = 0; + + value = valueOf(RegisterChaGriSet, &found); + if (chaGriSet < 0) { + QVERIFY2(!found, "ChaGriSet ne doit pas etre ecrit pour ce mode"); + } else { + QVERIFY(found); + QCOMPARE(int(value), chaGriSet); + } + + value = valueOf(RegisterStorCtlMod, &found); + QVERIFY2(found, "StorCtl_Mod est ecrit pour tous les modes"); + QCOMPARE(int(value), storCtlMod); + + value = valueOf(RegisterInWRte, &found); + if (qIsNaN(inWRte)) { + QVERIFY2(!found, "InWRte ne doit pas etre ecrit pour ce mode"); + } else { + QVERIFY(found); + QCOMPARE(value, inWRte); + } + + value = valueOf(RegisterOutWRte, &found); + if (qIsNaN(outWRte)) { + QVERIFY2(!found, "OutWRte ne doit pas etre ecrit pour ce mode"); + } else { + QVERIFY(found); + QCOMPARE(value, outWRte); + } + + // RvrtTms est arme pour tous les modes. + value = valueOf(RegisterRvrtTms, &found); + QVERIFY(found); + QCOMPARE(int(value), 300); +} + +void SunSpecFroniusTest::test_AC_11_write_order_follows_contract() +{ + // Ordre impose : ChaGriSet -> StorCtl_Mod -> InWRte / OutWRte -> RvrtTms. + const SetpointPlan plan = FroniusRules::planBatteryMode( + FroniusRules::BatteryModeLimit, 40.0, 70.0, 300); + + QVector ranks; + foreach (const Write &write, plan.writes) { + switch (write.reg) { + case RegisterChaGriSet: ranks.append(0); break; + case RegisterStorCtlMod: ranks.append(1); break; + case RegisterInWRte: + case RegisterOutWRte: ranks.append(2); break; + case RegisterRvrtTms: ranks.append(3); break; + } + } + + QVERIFY(!ranks.isEmpty()); + for (int i = 1; i < ranks.size(); i++) { + QVERIFY2(ranks.at(i) >= ranks.at(i - 1), "ordre d'ecriture non respecte"); + } + QCOMPARE(ranks.last(), 3); + + // Le mode FORCE_CHARGE ecrit ChaGriSet : il doit venir en premier. + const SetpointPlan forceCharge = FroniusRules::planBatteryMode( + FroniusRules::BatteryModeForceCharge, 60.0, 0.0, 300); + QCOMPARE(forceCharge.writes.first().reg, RegisterChaGriSet); +} + +// --------------------------------------------------------------------------- +// AC-12 - relecture du scale factor avant ecriture +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_12_plan_rereads_scale_factor_before_writing() +{ + // Les SF changent avec le firmware et en fonctionnement (auto-scale). Un SF + // mis en cache lors d'un cycle anterieur produit une consigne d'un facteur + // 10 a cote. + const SetpointPlan withRate = FroniusRules::planBatteryMode( + FroniusRules::BatteryModeLimit, 40.0, 70.0, 300); + QVERIFY2(withRate.rereadScaleFactor, + "toute ecriture de InWRte/OutWRte doit etre precedee d'une relecture de InOutWRte_SF"); + + const SetpointPlan generic = planSetpoint({{RegisterInWRte, 20.0}}, 300); + QVERIFY(generic.rereadScaleFactor); + + // Un plan qui n'ecrit aucune grandeur mise a l'echelle n'a pas besoin de + // relire le SF. + const SetpointPlan noRate = planSetpoint({{RegisterStorCtlMod, 3.0}}, 300); + QVERIFY(!noRate.rereadScaleFactor); +} + +// --------------------------------------------------------------------------- +// AC-13 - verification par relecture +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_13_plan_verifies_written_registers_by_readback() +{ + // Seule parade aux echecs silencieux : pilotage Modbus non autorise, charge + // reseau non autorisee, restriction d'IP, assistant tiers qui ecrase. + const SetpointPlan plan = FroniusRules::planBatteryMode( + FroniusRules::BatteryModeForceCharge, 60.0, 0.0, 300); + + QVERIFY(plan.verifyReadback.contains(RegisterStorCtlMod)); + QVERIFY(plan.verifyReadback.contains(RegisterOutWRte)); + QVERIFY(plan.verifyReadback.contains(RegisterChaGriSet)); + + // Tout registre ecrit, sauf RvrtTms, est relu. + foreach (const Write &write, plan.writes) { + if (write.reg == RegisterRvrtTms) + continue; + QVERIFY2(plan.verifyReadback.contains(write.reg), + "tout registre ecrit doit etre relu"); + } +} + +// --------------------------------------------------------------------------- +// AC-14 - classement des blocs du modele 160 par IDStr +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_14_blocks_are_classified_by_id_string() +{ + using namespace FroniusRules; + + QCOMPARE(classifyBlock("MPPT 1"), BlockRolePvString); + QCOMPARE(classifyBlock("MPPT 2"), BlockRolePvString); + QCOMPARE(classifyBlock("StCha 3"), BlockRoleBatteryCharge); + QCOMPARE(classifyBlock("StDisCha 4"), BlockRoleBatteryDischarge); + + // Insensible a la casse et aux espaces de bord. + QCOMPARE(classifyBlock(" mppt 1 "), BlockRolePvString); + QCOMPARE(classifyBlock("STDISCHA 4"), BlockRoleBatteryDischarge); + + // Sur un onduleur a 3 ou 4 MPPT, les blocs batterie ne sont pas en + // position 3 et 4 : c'est la chaine qui decide, jamais l'index. + QCOMPARE(classifyBlock("MPPT 3"), BlockRolePvString); + QCOMPARE(classifyBlock("MPPT 4"), BlockRolePvString); + QCOMPARE(classifyBlock("StCha 5"), BlockRoleBatteryCharge); + QCOMPARE(classifyBlock("StDisCha 6"), BlockRoleBatteryDischarge); +} + +void SunSpecFroniusTest::test_AC_14_st_dis_cha_is_not_confused_with_st_cha() +{ + // Piege : une implementation par contains() classerait StDisCha en StCha. + QCOMPARE(FroniusRules::classifyBlock("StDisCha 4"), FroniusRules::BlockRoleBatteryDischarge); + QVERIFY(FroniusRules::classifyBlock("StDisCha 4") != FroniusRules::BlockRoleBatteryCharge); +} + +void SunSpecFroniusTest::test_AC_14_unknown_id_string_is_not_classified() +{ + QCOMPARE(FroniusRules::classifyBlock(""), FroniusRules::BlockRoleUnknown); + QCOMPARE(FroniusRules::classifyBlock("Something else"), FroniusRules::BlockRoleUnknown); +} + +// --------------------------------------------------------------------------- +// AC-15 - agregats du modele 160 +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_AC_15_pv_power_sums_mppt_blocks_only() +{ + using namespace FroniusRules; + + const QList readings = { + {"MPPT 1", 1200.0}, + {"MPPT 2", 800.0}, + {"StCha 3", 500.0}, // batterie : ne compte pas dans le PV + {"StDisCha 4", 0.0}, + {"Something else", 999.0} // non classe : ne compte nulle part + }; + + QCOMPARE(pvPower(readings), 2000.0); +} + +void SunSpecFroniusTest::test_AC_15_battery_power_is_positive_when_discharging() +{ + using namespace FroniusRules; + + // DCW est un uint16, toujours positif : le signe est porte par le bloc + // actif, le bloc inactif etant a zero. + const QList readings = { + {"MPPT 1", 1200.0}, + {"StCha 3", 0.0}, + {"StDisCha 4", 750.0} + }; + + QCOMPARE(batteryPower(readings), 750.0); +} + +void SunSpecFroniusTest::test_AC_15_battery_power_is_negative_when_charging() +{ + using namespace FroniusRules; + + const QList readings = { + {"MPPT 1", 1200.0}, + {"StCha 3", 600.0}, + {"StDisCha 4", 0.0} + }; + + QCOMPARE(batteryPower(readings), -600.0); +} + +QTEST_MAIN(SunSpecFroniusTest) + +#include "tst_sunspec.moc" From abae49e058ef50dbf2d9ca2f779626f9c8af9dd5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:20:05 +0200 Subject: [PATCH 07/24] sunspec: widen storage charge and discharge rate range chargingRate and dischargingRate map onto InWRte and OutWRte of model 124, which are int16 setpoints ranging from -100.00 to +100.00 percent of WChaMax. Declaring them as 0..100 makes a forced charge structurally inexpressible: nymea rejects the negative action parameter before it ever reaches the plugin. Also fixes the default value, which was the boolean false on an int state. --- sunspec/integrationpluginsunspec.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json index 6696477..6c0951c 100644 --- a/sunspec/integrationpluginsunspec.json +++ b/sunspec/integrationpluginsunspec.json @@ -1442,10 +1442,10 @@ "displayName": "Charging rate", "displayNameEvent": "Charging rate changed", "type": "int", - "minValue": 0, + "minValue": -100, "maxValue": 100, "unit": "Percentage", - "defaultValue": false, + "defaultValue": 0, "writable": true, "displayNameAction": "Set charging rate" }, @@ -1455,10 +1455,10 @@ "displayName": "Discharging rate", "displayNameEvent": "Discharging rate changed", "type": "int", - "minValue": 0, + "minValue": -100, "maxValue": 100, "unit": "Percentage", - "defaultValue": false, + "defaultValue": 0, "writable": true, "displayNameAction": "Set discharging rate" }, From 4ec7aa50e8ea828990fd93b6b5f622fc97dac350 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:22:44 +0200 Subject: [PATCH 08/24] sunspec: reconcile storage states from the registers actions write onStorageBlockUpdated() fed chargingRate from wChaGra() and dischargingRate from wDisChaGra(). Those are the gradients, in percent of WChaMax per second, and read only on several inverters. The matching actions write InWRte and OutWRte, in percent of WChaMax. The user wrote one quantity and read back another. Feed the two rate states from inWRte() and outWRte(), and keep the gradients under names that cannot be mistaken for setpoints: chargeGradient and dischargeGradient, read only. enableDischarging was never reconciled either, while enableCharging was. It stayed frozen on the last written value and drifted silently as soon as the inverter changed StorCtl_Mod on its own. Reconcile both flags. The mapping moves into SunSpecStorageLogic so it can be exercised on a register dump without a running nymea. --- sunspec/integrationpluginsunspec.cpp | 17 ++++--- sunspec/integrationpluginsunspec.h | 1 + sunspec/integrationpluginsunspec.json | 18 +++++++ sunspec/sunspec.pro | 2 + sunspec/sunspecstoragelogic.cpp | 59 ++++++++++++++++++++++ sunspec/sunspecstoragelogic.h | 70 +++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 sunspec/sunspecstoragelogic.cpp create mode 100644 sunspec/sunspecstoragelogic.h diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 2ef9d3d..5c50122 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -1616,12 +1616,17 @@ void IntegrationPluginSunSpec::onStorageBlockUpdated() thing->setStateValue(sunspecStorageConnectedStateTypeId, true); thing->setStateValue(sunspecStorageVersionStateTypeId, model->commonModelInfo().versionString); - thing->setStateValue(sunspecStorageBatteryCriticalStateTypeId, storage->chaState() < 5); - thing->setStateValue(sunspecStorageBatteryLevelStateTypeId, qRound(storage->chaState())); - thing->setStateValue(sunspecStorageGridChargingStateTypeId, storage->chaGriSet() == SunSpecStorageModel::ChagrisetGrid); - thing->setStateValue(sunspecStorageEnableChargingStateTypeId, storage->storCtlMod().testFlag(SunSpecStorageModel::Storctl_modCharge)); - thing->setStateValue(sunspecStorageChargingRateStateTypeId, storage->wChaGra()); - thing->setStateValue(sunspecStorageDischargingRateStateTypeId, storage->wDisChaGra()); + const SunSpecStorageLogic::StorageStates states = SunSpecStorageLogic::readStates(storage); + + thing->setStateValue(sunspecStorageBatteryCriticalStateTypeId, states.batteryCritical); + thing->setStateValue(sunspecStorageBatteryLevelStateTypeId, states.batteryLevel); + thing->setStateValue(sunspecStorageGridChargingStateTypeId, states.gridCharging); + thing->setStateValue(sunspecStorageEnableChargingStateTypeId, states.enableCharging); + thing->setStateValue(sunspecStorageEnableDischargingStateTypeId, states.enableDischarging); + thing->setStateValue(sunspecStorageChargingRateStateTypeId, states.chargingRate); + thing->setStateValue(sunspecStorageDischargingRateStateTypeId, states.dischargingRate); + thing->setStateValue(sunspecStorageChargeGradientStateTypeId, states.chargeGradient); + thing->setStateValue(sunspecStorageDischargeGradientStateTypeId, states.dischargeGradient); switch (storage->chaSt()) { case SunSpecStorageModel::ChastOff: diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index d6a09b4..72ffc36 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -33,6 +33,7 @@ #include #include "sunspecthing.h" +#include "sunspecstoragelogic.h" #include "extern-plugininfo.h" #include diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json index 6c0951c..81bf635 100644 --- a/sunspec/integrationpluginsunspec.json +++ b/sunspec/integrationpluginsunspec.json @@ -1462,6 +1462,24 @@ "writable": true, "displayNameAction": "Set discharging rate" }, + { + "id": "47035ac0-d054-491a-969d-b2aaa9168b0a", + "name": "chargeGradient", + "displayName": "Charging gradient", + "displayNameEvent": "Charging gradient changed", + "type": "double", + "unit": "Percentage", + "defaultValue": 0 + }, + { + "id": "ad00480a-54a3-498a-b937-f9943a98d453", + "name": "dischargeGradient", + "displayName": "Discharging gradient", + "displayNameEvent": "Discharging gradient changed", + "type": "double", + "unit": "Percentage", + "defaultValue": 0 + }, { "id": "fca0c151-d8fa-4d6e-a5d7-b3bc965abc09", "name": "version", diff --git a/sunspec/sunspec.pro b/sunspec/sunspec.pro index df3837a..6496657 100644 --- a/sunspec/sunspec.pro +++ b/sunspec/sunspec.pro @@ -5,10 +5,12 @@ SOURCES += \ integrationpluginsunspec.cpp \ solaredgebattery.cpp \ sunspecdiscovery.cpp \ + sunspecstoragelogic.cpp \ sunspecthing.cpp HEADERS += \ integrationpluginsunspec.h \ solaredgebattery.h \ sunspecdiscovery.h \ + sunspecstoragelogic.h \ sunspecthing.h diff --git a/sunspec/sunspecstoragelogic.cpp b/sunspec/sunspecstoragelogic.cpp new file mode 100644 index 0000000..c2b15d9 --- /dev/null +++ b/sunspec/sunspecstoragelogic.cpp @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* 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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "sunspecstoragelogic.h" + +#include + +namespace SunSpecStorageLogic { + +StorageStates readStates(SunSpecStorageModel *storage) +{ + StorageStates states; + if (!storage) + return states; + + /* InWRte / OutWRte are what the chargingRate / dischargingRate actions + * write. Reading the gradients here instead would mean the user writes one + * quantity and reads back another. */ + states.chargingRate = storage->inWRte(); + states.dischargingRate = storage->outWRte(); + + /* The gradients stay available, under names that cannot be mistaken for the + * setpoints above. */ + states.chargeGradient = storage->wChaGra(); + states.dischargeGradient = storage->wDisChaGra(); + + const SunSpecStorageModel::Storctl_modFlags controlMode = storage->storCtlMod(); + states.enableCharging = controlMode.testFlag(SunSpecStorageModel::Storctl_modCharge); + states.enableDischarging = controlMode.testFlag(SunSpecStorageModel::Storctl_modDiScharge); + + states.gridCharging = (storage->chaGriSet() == SunSpecStorageModel::ChagrisetGrid); + + states.batteryLevel = qRound(storage->chaState()); + states.batteryCritical = storage->chaState() < 5; + + return states; +} + +} diff --git a/sunspec/sunspecstoragelogic.h b/sunspec/sunspecstoragelogic.h new file mode 100644 index 0000000..815c79c --- /dev/null +++ b/sunspec/sunspecstoragelogic.h @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2013 - 2024, nymea GmbH +* Copyright (C) 2024 - 2025, chargebyte austria GmbH +* +* 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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef SUNSPECSTORAGELOGIC_H +#define SUNSPECSTORAGELOGIC_H + +#include + +#include + +/* Decision logic of the storage (model 124) handling, kept free of the + * integration framework so it can be exercised without a running nymea. + * + * readStates() maps a storage block update onto the state values the plugin + * publishes. Having it in one place is what makes it possible to check that a + * state is fed from the very register the matching action writes to. + */ +namespace SunSpecStorageLogic { + +struct StorageStates { + /* Percent of WChaMax, from InWRte / OutWRte - the very registers the + * chargingRate / dischargingRate actions write to. */ + double chargingRate = 0; + double dischargingRate = 0; + + /* Percent of WChaMax per second, from WChaGra / WDisChaGra. A different + * quantity from the two above, and read only on several inverters. */ + double chargeGradient = 0; + double dischargeGradient = 0; + + /* Both flags are reconciled from StorCtl_Mod. Reconciling only one lets the + * other drift silently as soon as the inverter changes it on its own. */ + bool enableCharging = false; + bool enableDischarging = false; + + bool gridCharging = false; + + int batteryLevel = 0; + bool batteryCritical = false; +}; + +/* Value range of the InWRte / OutWRte setpoints of model 124. */ +const double MinimumRatePercent = -100.0; +const double MaximumRatePercent = 100.0; + +StorageStates readStates(SunSpecStorageModel *storage); + +} + +#endif // SUNSPECSTORAGELOGIC_H From c5c3f46f570e8211a924d22f3e5dc8e51cc5cfe4 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:26:01 +0200 Subject: [PATCH 09/24] sunspec: arm the storage revert timer after every setpoint setInOutWRteRvrtTms() existed but was never called. InOutWRte_RvrtTms is the delay after which the inverter drops the setpoint and resumes its native behaviour. Left at 0, a setpoint stays active indefinitely, HEMS stopped, network lost or process crashed included - the battery stays where the last command put it. Add a per-thing revertTime parameter, 0..28800 s, defaulting to 0 so the generic vendor keeps its current behaviour, and route every setpoint through SunSpecStorageLogic::planSetpoint(). The plan orders the writes - ChaGriSet, StorCtl_Mod, then the rates - and appends the revert timer last, so the timer never starts counting against a setpoint that has not been written yet. Writes are issued sequentially rather than in parallel: the order is part of the contract with the hardware. --- sunspec/integrationpluginsunspec.cpp | 144 +++++++++++++------------- sunspec/integrationpluginsunspec.h | 5 + sunspec/integrationpluginsunspec.json | 10 ++ sunspec/sunspecstoragelogic.cpp | 67 ++++++++++++ sunspec/sunspecstoragelogic.h | 52 +++++++++- 5 files changed, 205 insertions(+), 73 deletions(-) diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 5c50122..943e1d1 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -469,97 +469,101 @@ void IntegrationPluginSunSpec::executeAction(ThingActionInfo *info) return; } + /* Every setpoint goes through a plan rather than a lone write. The plan + * puts the registers in the order the hardware expects and closes the + * sequence with the revert timer, so that a setpoint left behind by a + * stopped HEMS expires instead of holding the battery forever. */ + QVector setpoints; + if (action.actionTypeId() == sunspecStorageGridChargingActionTypeId) { bool gridCharging = action.param(sunspecStorageGridChargingActionGridChargingParamTypeId).value().toBool(); - QModbusReply *reply = storage->setChaGriSet(gridCharging ? SunSpecStorageModel::ChagrisetGrid : SunSpecStorageModel::ChagrisetPv); - if (!reply) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - - connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); - connect(reply, &QModbusReply::finished, info, [info, reply]{ - if (reply->error() != QModbusDevice::NoError) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - info->finish(Thing::ThingErrorNoError); - }); + setpoints.append({SunSpecStorageLogic::RegisterChaGriSet, + gridCharging ? double(SunSpecStorageModel::ChagrisetGrid) : double(SunSpecStorageModel::ChagrisetPv)}); } else if (action.actionTypeId() == sunspecStorageEnableChargingActionTypeId) { SunSpecStorageModel::Storctl_modFlags controlModeFlags = storage->storCtlMod(); bool enabled = action.param(sunspecStorageEnableChargingActionEnableChargingParamTypeId).value().toBool(); controlModeFlags.setFlag(SunSpecStorageModel::Storctl_modCharge, enabled); - QModbusReply *reply = storage->setStorCtlMod(controlModeFlags); - if (!reply) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); - connect(reply, &QModbusReply::finished, info, [info, reply, enabled]{ - if (reply->error() != QModbusDevice::NoError) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - - info->thing()->setStateValue(sunspecStorageEnableChargingStateTypeId, enabled); - info->finish(Thing::ThingErrorNoError); - }); + setpoints.append({SunSpecStorageLogic::RegisterStorCtlMod, double(int(controlModeFlags))}); } else if (action.actionTypeId() == sunspecStorageEnableDischargingActionTypeId) { SunSpecStorageModel::Storctl_modFlags controlModeFlags = storage->storCtlMod(); bool enabled = action.param(sunspecStorageEnableDischargingActionEnableDischargingParamTypeId).value().toBool(); controlModeFlags.setFlag(SunSpecStorageModel::Storctl_modDiScharge, enabled); - QModbusReply *reply = storage->setStorCtlMod(controlModeFlags); - if (!reply) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); - connect(reply, &QModbusReply::finished, info, [info, reply, enabled]{ - if (reply->error() != QModbusDevice::NoError) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - - info->thing()->setStateValue(sunspecStorageEnableDischargingStateTypeId, enabled); - info->finish(Thing::ThingErrorNoError); - }); + setpoints.append({SunSpecStorageLogic::RegisterStorCtlMod, double(int(controlModeFlags))}); } else if (action.actionTypeId() == sunspecStorageChargingRateActionTypeId) { - QModbusReply *reply = storage->setInWRte(action.param(sunspecStorageChargingRateActionChargingRateParamTypeId).value().toInt()); - if (!reply) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); - connect(reply, &QModbusReply::finished, info, [info, reply]{ - if (reply->error() != QModbusDevice::NoError) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - info->finish(Thing::ThingErrorNoError); - }); + setpoints.append({SunSpecStorageLogic::RegisterInWRte, + action.param(sunspecStorageChargingRateActionChargingRateParamTypeId).value().toDouble()}); } else if (action.actionTypeId() == sunspecStorageDischargingRateActionTypeId) { - QModbusReply *reply = storage->setOutWRte(action.param(sunspecStorageDischargingRateActionDischargingRateParamTypeId).value().toInt()); - if (!reply) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); - connect(reply, &QModbusReply::finished, info, [info, reply]{ - if (reply->error() != QModbusDevice::NoError) { - info->finish(Thing::ThingErrorHardwareFailure); - return; - } - info->finish(Thing::ThingErrorNoError); - }); + setpoints.append({SunSpecStorageLogic::RegisterOutWRte, + action.param(sunspecStorageDischargingRateActionDischargingRateParamTypeId).value().toDouble()}); } else { Q_ASSERT_X(false, "executeAction", QString("Unhandled action: %1").arg(action.actionTypeId().toString()).toUtf8()); + info->finish(Thing::ThingErrorActionTypeNotFound); + return; } + const uint revertTime = thing->paramValue(sunspecStorageThingRevertTimeParamTypeId).toUInt(); + executeSetpointPlan(info, storage, SunSpecStorageLogic::planSetpoint(setpoints, revertTime)); + } else { Q_ASSERT_X(false, "executeAction", QString("Unhandled thingClassId: %1").arg(info->thing()->thingClassId().toString()).toUtf8()); } } +QModbusReply *IntegrationPluginSunSpec::writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write) +{ + switch (write.reg) { + case SunSpecStorageLogic::RegisterChaGriSet: + return storage->setChaGriSet(write.value != 0 ? SunSpecStorageModel::ChagrisetGrid + : SunSpecStorageModel::ChagrisetPv); + case SunSpecStorageLogic::RegisterStorCtlMod: + return storage->setStorCtlMod(SunSpecStorageModel::Storctl_modFlags(int(write.value))); + case SunSpecStorageLogic::RegisterInWRte: + return storage->setInWRte(write.value); + case SunSpecStorageLogic::RegisterOutWRte: + return storage->setOutWRte(write.value); + case SunSpecStorageLogic::RegisterRvrtTms: + return storage->setInOutWRteRvrtTms(static_cast(write.value)); + } + return nullptr; +} + +/* The writes are issued one after another rather than all at once: the order is + * part of the contract with the hardware, and several inverters ask for + * sequential requests. */ +void IntegrationPluginSunSpec::executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index) +{ + if (index >= plan.writes.count()) { + info->finish(Thing::ThingErrorNoError); + return; + } + + const SunSpecStorageLogic::Write write = plan.writes.at(index); + + QModbusReply *reply = writeSetpointRegister(storage, write); + if (!reply) { + qCWarning(dcSunSpec()) << "Could not write" << SunSpecStorageLogic::registerName(write.reg) << "on" << info->thing(); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + connect(reply, &QModbusReply::finished, reply, &QModbusReply::deleteLater); + connect(reply, &QModbusReply::finished, info, [this, info, storage, plan, index, write, reply] { + if (reply->error() != QModbusDevice::NoError) { + qCWarning(dcSunSpec()) << "Error writing" << SunSpecStorageLogic::registerName(write.reg) + << "on" << info->thing() << reply->errorString(); + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + + executeSetpointWrite(info, storage, plan, index + 1); + }); +} + +void IntegrationPluginSunSpec::executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan) +{ + executeSetpointWrite(info, storage, plan, 0); +} + Thing *IntegrationPluginSunSpec::getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId) { foreach (Thing *thing, myThings()) { diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 72ffc36..968832c 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -109,6 +109,11 @@ private: bool hasManufacturer(const QStringList &manufacturers, const QString &manufacturer); void markThingStatesDisconnected(Thing *thing); + // Storage setpoints + void executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan); + void executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index); + QModbusReply *writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write); + private slots: void onRefreshTimer(); void onPluginConfigurationChanged(const ParamTypeId ¶mTypeId, const QVariant &value); diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json index 81bf635..3fb302d 100644 --- a/sunspec/integrationpluginsunspec.json +++ b/sunspec/integrationpluginsunspec.json @@ -1349,6 +1349,16 @@ "displayName": "Serial number", "type": "QString", "defaultValue": "Unkown" + }, + { + "id": "74d530d1-6fd3-4afa-8acd-1e91041f88b2", + "name":"revertTime", + "displayName": "Setpoint revert time", + "type": "uint", + "unit": "Seconds", + "minValue": 0, + "maxValue": 28800, + "defaultValue": 0 } ], "stateTypes":[ diff --git a/sunspec/sunspecstoragelogic.cpp b/sunspec/sunspecstoragelogic.cpp index c2b15d9..5510a9c 100644 --- a/sunspec/sunspecstoragelogic.cpp +++ b/sunspec/sunspecstoragelogic.cpp @@ -56,4 +56,71 @@ StorageStates readStates(SunSpecStorageModel *storage) return states; } +bool isScaledRate(Register reg) +{ + return reg == RegisterInWRte || reg == RegisterOutWRte; +} + +QString registerName(Register reg) +{ + switch (reg) { + case RegisterChaGriSet: return QStringLiteral("ChaGriSet"); + case RegisterStorCtlMod: return QStringLiteral("StorCtl_Mod"); + case RegisterInWRte: return QStringLiteral("InWRte"); + case RegisterOutWRte: return QStringLiteral("OutWRte"); + case RegisterRvrtTms: return QStringLiteral("InOutWRte_RvrtTms"); + } + return QString(); +} + +/* Rank within the write order: ChaGriSet, then StorCtl_Mod, then the rates, + * then the revert timer. Arming the timer first would start it counting against + * a setpoint that has not been written yet. */ +static int writeOrderRank(Register reg) +{ + switch (reg) { + case RegisterChaGriSet: return 0; + case RegisterStorCtlMod: return 1; + case RegisterInWRte: + case RegisterOutWRte: return 2; + case RegisterRvrtTms: return 3; + } + return 3; +} + +SetpointPlan planSetpoint(const QVector &setpoints, quint16 revertTime) +{ + SetpointPlan plan; + + QVector writes; + foreach (const Write &write, setpoints) { + /* The revert time is appended below, once, in last position. A caller + * passing it explicitly must not end up with two of them. */ + if (write.reg == RegisterRvrtTms) + continue; + + if (isScaledRate(write.reg)) + plan.rereadScaleFactor = true; + + writes.append(write); + } + + std::stable_sort(writes.begin(), writes.end(), [](const Write &left, const Write &right) { + return writeOrderRank(left.reg) < writeOrderRank(right.reg); + }); + + /* Every register that carries the intent is read back afterwards. The + * revert timer is excluded: it is a countdown, so its read-back value is + * not expected to match what was written. */ + foreach (const Write &write, writes) { + if (!plan.verifyReadback.contains(write.reg)) + plan.verifyReadback.append(write.reg); + } + + plan.writes = writes; + plan.writes.append({RegisterRvrtTms, static_cast(revertTime)}); + + return plan; +} + } diff --git a/sunspec/sunspecstoragelogic.h b/sunspec/sunspecstoragelogic.h index 815c79c..196b311 100644 --- a/sunspec/sunspecstoragelogic.h +++ b/sunspec/sunspecstoragelogic.h @@ -31,12 +31,47 @@ /* Decision logic of the storage (model 124) handling, kept free of the * integration framework so it can be exercised without a running nymea. * - * readStates() maps a storage block update onto the state values the plugin - * publishes. Having it in one place is what makes it possible to check that a - * state is fed from the very register the matching action writes to. + * Two things live here: + * + * - readStates() maps a storage block update onto the state values the plugin + * publishes. Having it in one place is what makes it possible to check that + * a state is fed from the very register the matching action writes to; + * - planSetpoint() turns a set of setpoints into an ordered sequence of + * register writes. Order matters on real hardware, and the revert timer + * must close every sequence. */ namespace SunSpecStorageLogic { +/* The registers of model 124 this plugin ever writes. */ +enum Register { + RegisterChaGriSet, + RegisterStorCtlMod, + RegisterInWRte, + RegisterOutWRte, + RegisterRvrtTms +}; + +struct Write { + Register reg; + double value; +}; + +struct SetpointPlan { + /* Scale factors are not necessarily static: they may change with firmware + * and at runtime. Whenever a scaled quantity is written, its scale factor + * should be read again immediately before the write rather than reused + * from an earlier poll. */ + bool rereadScaleFactor = false; + + /* Ordered: ChaGriSet, StorCtl_Mod, InWRte/OutWRte, RvrtTms last. */ + QVector writes; + + /* Registers to read back and compare after the sequence. Several inverters + * accept a write and then ignore it without raising a Modbus exception, so + * an unverified setpoint is an unapplied setpoint. */ + QVector verifyReadback; +}; + struct StorageStates { /* Percent of WChaMax, from InWRte / OutWRte - the very registers the * chargingRate / dischargingRate actions write to. */ @@ -63,8 +98,19 @@ struct StorageStates { const double MinimumRatePercent = -100.0; const double MaximumRatePercent = 100.0; +/* Range of InOutWRte_RvrtTms. */ +const int MaximumRevertTimeSeconds = 28800; + StorageStates readStates(SunSpecStorageModel *storage); +/* True for the registers carried by InOutWRte_SF, i.e. those whose write should + * be preceded by a fresh read of that scale factor. */ +bool isScaledRate(Register reg); + +QString registerName(Register reg); + +SetpointPlan planSetpoint(const QVector &setpoints, quint16 revertTime); + } #endif // SUNSPECSTORAGELOGIC_H From 89406495a8ca0d531aa534047350f763262d32ce Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:33:12 +0200 Subject: [PATCH 10/24] sunspec: add Fronius vendor with its own control rules Adds a fronius vendor and a froniusConnection thing class alongside the generic sunspec and solarEdge ones, following the SolarEdge mechanism but none of its workarounds - no proprietary battery block and no PV production computed by difference, since the GEN24 exposes model 160. FroniusRules holds the vendor decisions as pure functions: - discovery only keeps devices announcing Fronius as manufacturer; - the byte order is the SunSpec default, not the forced big endian that is specific to SolarEdge, and no endianness parameter is exposed; - meterSlaveId defaults to 200, 0 meaning no meter on this connection; - a hard 7 s floor on the poll interval. Polling faster can push the inverter offline towards the vendor portal, and recovery needs an AC+DC power cycle on site, so this is a guard in the code rather than a configurable default; - the registers the inverter accepts and then silently ignores are refused before anything reaches the wire; - the six HEMS battery modes map onto ChaGriSet, StorCtl_Mod, InWRte and OutWRte, negative meaning charge; - model 160 blocks are classified by IDStr, never by index: on a three or four MPPT inverter the battery blocks are not in position 3 and 4. Storages under a Fronius connection default revertTime to 300 s. Setpoints now reread the scale factor before writing and read the written registers back afterwards, reporting a mismatch through a new controlHealthy state - the only defence against writes that are accepted and then ignored without a Modbus exception. Byte order and the revert timer semantics on model 124 are inferences, not measurements; see specs/fronius-sunspec.md, H-2 and H-3. --- sunspec/froniusrules.cpp | 172 ++++++++++++++++++++++++ sunspec/froniusrules.h | 126 ++++++++++++++++++ sunspec/integrationpluginsunspec.cpp | 180 +++++++++++++++++++++++++- sunspec/integrationpluginsunspec.h | 8 ++ sunspec/integrationpluginsunspec.json | 93 +++++++++++++ sunspec/sunspec.pro | 2 + 6 files changed, 575 insertions(+), 6 deletions(-) create mode 100644 sunspec/froniusrules.cpp create mode 100644 sunspec/froniusrules.h diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp new file mode 100644 index 0000000..d655b91 --- /dev/null +++ b/sunspec/froniusrules.cpp @@ -0,0 +1,172 @@ +// 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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "froniusrules.h" + +using namespace SunSpecStorageLogic; + +namespace FroniusRules { + +bool matchesManufacturer(const QStringList &manufacturers) +{ + foreach (const QString &name, manufacturers) { + if (name.contains(QStringLiteral("fronius"), Qt::CaseInsensitive)) + return true; + } + return false; +} + +SunSpecDataPoint::ByteOrder byteOrder() +{ + return SunSpecDataPoint::ByteOrderLittleEndian; +} + +int clampUpdateInterval(int requestedSeconds) +{ + return qMax(requestedSeconds, MinimumUpdateIntervalSeconds); +} + +bool isWriteForbidden(const QString ®isterName) +{ + static const QStringList forbidden = { + QStringLiteral("WChaMax"), + QStringLiteral("WChaGra"), + QStringLiteral("WDisChaGra"), + QStringLiteral("InOutWRte_WinTms"), + QStringLiteral("InOutWRte_RmpTms"), + /* VAChaMax is doubly unusable: the write is ignored and its scale + * factor is not supported either. */ + QStringLiteral("VAChaMax") + }; + + foreach (const QString &name, forbidden) { + if (registerName.compare(name, Qt::CaseInsensitive) == 0) + return true; + } + return false; +} + +SetpointPlan planBatteryMode(BatteryMode mode, double chargeLimit, double dischargeLimit, quint16 revertTime) +{ + /* StorCtl_Mod is a bitfield: bit 0 CHARGE, bit 1 DISCHARGE. ChaGriSet is + * 0 PV, 1 GRID. A register absent from the vector below is deliberately not + * written for that mode. + * + * planSetpoint() puts these in the mandated order and appends the revert + * timer, so the order they are listed in here does not matter. */ + QVector setpoints; + + switch (mode) { + case BatteryModeNormal: + setpoints.append({RegisterStorCtlMod, 0}); + setpoints.append({RegisterOutWRte, 100}); + break; + + case BatteryModeNoDischarge: + setpoints.append({RegisterStorCtlMod, 2}); + setpoints.append({RegisterOutWRte, 0}); + break; + + case BatteryModeNoCharge: + setpoints.append({RegisterStorCtlMod, 1}); + setpoints.append({RegisterInWRte, 0}); + break; + + case BatteryModeFrozen: + setpoints.append({RegisterStorCtlMod, 3}); + setpoints.append({RegisterInWRte, 0}); + setpoints.append({RegisterOutWRte, 0}); + break; + + case BatteryModeForceCharge: + /* Charging from the grid has to be allowed on the inverter as well; + * ChaGriSet alone is ANDed with a setting of the component + * configuration and fails silently if that one is off. */ + setpoints.append({RegisterChaGriSet, 1}); + setpoints.append({RegisterStorCtlMod, 2}); + /* Negative is charging. */ + setpoints.append({RegisterOutWRte, -chargeLimit}); + break; + + case BatteryModeLimit: + setpoints.append({RegisterStorCtlMod, 3}); + setpoints.append({RegisterInWRte, chargeLimit}); + setpoints.append({RegisterOutWRte, dischargeLimit}); + break; + } + + return planSetpoint(setpoints, revertTime); +} + +BlockRole classifyBlock(const QString &inputIdString) +{ + const QString id = inputIdString.trimmed(); + if (id.isEmpty()) + return BlockRoleUnknown; + + /* Prefix match, not contains: the role is decided by the string, never by + * the block index. StDisCha is checked first so that no cascade can ever + * swallow it into the charge case. */ + if (id.startsWith(QStringLiteral("StDisCha"), Qt::CaseInsensitive)) + return BlockRoleBatteryDischarge; + + if (id.startsWith(QStringLiteral("StCha"), Qt::CaseInsensitive)) + return BlockRoleBatteryCharge; + + if (id.startsWith(QStringLiteral("MPPT"), Qt::CaseInsensitive)) + return BlockRolePvString; + + return BlockRoleUnknown; +} + +double pvPower(const QList &readings) +{ + double total = 0; + foreach (const ModuleReading &reading, readings) { + if (classifyBlock(reading.inputIdString) == BlockRolePvString) + total += reading.dcPower; + } + return total; +} + +double batteryPower(const QList &readings) +{ + double charging = 0; + double discharging = 0; + + foreach (const ModuleReading &reading, readings) { + switch (classifyBlock(reading.inputIdString)) { + case BlockRoleBatteryCharge: + charging += reading.dcPower; + break; + case BlockRoleBatteryDischarge: + discharging += reading.dcPower; + break; + default: + break; + } + } + + return discharging - charging; +} + +} diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h new file mode 100644 index 0000000..ced9824 --- /dev/null +++ b/sunspec/froniusrules.h @@ -0,0 +1,126 @@ +// 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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef FRONIUSRULES_H +#define FRONIUSRULES_H + +#include +#include +#include + +#include + +#include "sunspecstoragelogic.h" + +/* Vendor specific rules for Fronius GEN24 inverters. + * + * Everything here is a pure decision: no Modbus, no thing, no timer. The + * plugin asks these functions what to do; the answers are what the unit tests + * pin down. See specs/fronius-sunspec.md. + */ +namespace FroniusRules { + +/* Polling. The manufacturer's support recommends staying above seven seconds + * for third party applications. Below that, communication load can push the + * inverter offline towards the manufacturer's portal, and recovery needs an + * AC+DC power cycle on site. This floor is a guard in the code, not a + * configurable default. */ +const int MinimumUpdateIntervalSeconds = 7; +const int DefaultUpdateIntervalSeconds = 10; +const int DefaultTimeoutMilliSeconds = 2000; +const int DefaultNumberOfRetries = 3; + +/* Revert timer armed with every setpoint, so that a setpoint left behind by a + * stopped or crashed HEMS expires instead of holding the battery forever. + * + * WORKING HYPOTHESIS: the timer semantics are documented in the manual's + * general chapter and illustrated on model 123, not on model 124. This must be + * verified experimentally on model 124 before being frozen. If it does not + * hold, fall back to 0 plus an application level watchdog in the HEMS. */ +const int DefaultRevertTimeSeconds = 300; + +/* Default unit IDs. The inverter is fixed at 1 over TCP; the meter defaults to + * 200, with 201, 202... for secondary meters. 0 means "no meter on this + * connection". */ +const int InverterSlaveId = 1; +const int DefaultMeterSlaveId = 200; + +bool matchesManufacturer(const QStringList &manufacturers); + +/* INFERRED: the byte order of the GEN24 is not documented by the manufacturer, + * so the SunSpec default is assumed. The forced big endian of the SolarEdge + * vendor is an anomaly specific to SolarEdge and must not be extended here. + * To be confirmed on the bench, then on hardware. */ +SunSpecDataPoint::ByteOrder byteOrder(); + +int clampUpdateInterval(int requestedSeconds); + +/* Registers the GEN24 accepts and then ignores without raising a Modbus + * exception. Writing them fails invisibly, so they are refused before being + * put on the wire. */ +bool isWriteForbidden(const QString ®isterName); + +enum BatteryMode { + BatteryModeNormal, + BatteryModeNoDischarge, + BatteryModeNoCharge, + BatteryModeFrozen, + BatteryModeForceCharge, + BatteryModeLimit +}; + +/* Sign convention of the manufacturer: negative is charging, positive is + * discharging. chargeLimit and dischargeLimit are percentages of WChaMax and + * are only used by the modes that need them. */ +SunSpecStorageLogic::SetpointPlan planBatteryMode(BatteryMode mode, + double chargeLimit, + double dischargeLimit, + quint16 revertTime); + +/* Model 160 repeating blocks are identified by their IDStr, never by their + * index: on a three or four MPPT inverter the battery blocks are not in + * position 3 and 4. */ +enum BlockRole { + BlockRolePvString, + BlockRoleBatteryCharge, + BlockRoleBatteryDischarge, + BlockRoleUnknown +}; + +BlockRole classifyBlock(const QString &inputIdString); + +struct ModuleReading { + QString inputIdString; + double dcPower; +}; + +/* Sum of the DC power of the PV string blocks. */ +double pvPower(const QList &readings); + +/* Discharge minus charge, so positive means discharging. DCW is a uint16 and + * therefore always positive: the sign is carried by whichever block is active, + * the inactive one reading zero. */ +double batteryPower(const QList &readings); + +} + +#endif // FRONIUSRULES_H diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 943e1d1..133cedd 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -66,21 +66,27 @@ void IntegrationPluginSunSpec::init() // SunSpec connection params m_connectionDiscoverySlaveIdParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionDiscoverySlaveIdParamTypeId); m_connectionDiscoverySlaveIdParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionDiscoverySlaveIdParamTypeId); + m_connectionDiscoverySlaveIdParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionDiscoverySlaveIdParamTypeId); m_connectionMacAddressParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingMacAddressParamTypeId); m_connectionMacAddressParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingMacAddressParamTypeId); + m_connectionMacAddressParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingMacAddressParamTypeId); m_connectionHostNameParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingHostNameParamTypeId); m_connectionHostNameParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingHostNameParamTypeId); + m_connectionHostNameParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingHostNameParamTypeId); m_connectionAddressParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingAddressParamTypeId); m_connectionAddressParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingAddressParamTypeId); + m_connectionAddressParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingAddressParamTypeId); m_connectionPortParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingPortParamTypeId); m_connectionPortParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingPortParamTypeId); + m_connectionPortParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingPortParamTypeId); m_connectionSlaveIdParamTypeIds.insert(sunspecConnectionThingClassId, sunspecConnectionThingSlaveIdParamTypeId); m_connectionSlaveIdParamTypeIds.insert(solarEdgeConnectionThingClassId, solarEdgeConnectionThingSlaveIdParamTypeId); + m_connectionSlaveIdParamTypeIds.insert(froniusConnectionThingClassId, froniusConnectionThingSlaveIdParamTypeId); // Params for sunspec things m_modelIdParamTypeIds.insert(sunspecSinglePhaseInverterThingClassId, sunspecSinglePhaseInverterThingModelIdParamTypeId); @@ -140,6 +146,8 @@ void IntegrationPluginSunSpec::discoverThings(ThingDiscoveryInfo *info) SunSpecDataPoint::ByteOrder byteOrder = SunSpecDataPoint::ByteOrderLittleEndian; if (info->thingClassId() == solarEdgeConnectionThingClassId) byteOrder = SunSpecDataPoint::ByteOrderBigEndian; + else if (info->thingClassId() == froniusConnectionThingClassId) + byteOrder = FroniusRules::byteOrder(); quint16 discoverySlaveId = info->params().paramValue(m_connectionDiscoverySlaveIdParamTypeIds.value(info->thingClassId())).toUInt(); @@ -165,6 +173,12 @@ void IntegrationPluginSunSpec::discoverThings(ThingDiscoveryInfo *info) } else { manufacturer = "SolarEdge"; } + } else if (info->thingClassId() == froniusConnectionThingClassId) { + if (!FroniusRules::matchesManufacturer(result.modelManufacturers)) { + // Only offer devices that actually announce themselves as Fronius. + continue; + } + manufacturer = "Fronius"; } else if (info->thingClassId() == sunspecConnectionThingClassId) { // There are some issues regarding the sunspec implementation of kostal. // Full support of meter, inverter and storage will be provided in the kostal plugin which makes @@ -416,7 +430,7 @@ void IntegrationPluginSunSpec::postSetupThing(Thing *thing) // Create the refresh timer if not already set up if (!m_refreshTimer) { qCDebug(dcSunSpec()) << "Starting refresh timer"; - int refreshTime = configValue(sunSpecPluginUpdateIntervalParamTypeId).toInt(); + int refreshTime = effectiveUpdateInterval(); m_refreshTimer = hardwareManager()->pluginTimerManager()->registerTimer(refreshTime); connect(m_refreshTimer, &PluginTimer::timeout, this, &IntegrationPluginSunSpec::onRefreshTimer); } @@ -509,6 +523,36 @@ void IntegrationPluginSunSpec::executeAction(ThingActionInfo *info) } } +/* The refresh timer is plugin wide, not per connection, so the Fronius polling + * floor has to be applied to it: as soon as one Fronius connection is + * configured, the whole plugin stops polling faster than the floor. + * + * Going below it can push the inverter offline towards the manufacturer's + * portal, and recovery then needs an AC+DC power cycle on site. This is a guard + * in the code on purpose, not a configurable default - including in debug. */ +int IntegrationPluginSunSpec::effectiveUpdateInterval() +{ + const int configured = configValue(sunSpecPluginUpdateIntervalParamTypeId).toInt(); + + if (myThings().filterByThingClassId(froniusConnectionThingClassId).isEmpty()) + return configured; + + const int clamped = FroniusRules::clampUpdateInterval(configured); + if (clamped != configured) { + qCWarning(dcSunSpec()) << "Update interval of" << configured << "s is below the" + << FroniusRules::MinimumUpdateIntervalSeconds + << "s floor required while a Fronius connection is configured. Using" + << clamped << "s instead."; + } + return clamped; +} + +bool IntegrationPluginSunSpec::isFroniusChild(const ThingId &parentId) +{ + Thing *parent = myThings().findById(parentId); + return parent && parent->thingClassId() == froniusConnectionThingClassId; +} + QModbusReply *IntegrationPluginSunSpec::writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write) { switch (write.reg) { @@ -533,12 +577,32 @@ QModbusReply *IntegrationPluginSunSpec::writeSetpointRegister(SunSpecStorageMode void IntegrationPluginSunSpec::executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index) { if (index >= plan.writes.count()) { - info->finish(Thing::ThingErrorNoError); + if (plan.verifyReadback.isEmpty()) { + info->finish(Thing::ThingErrorNoError); + return; + } + + refreshStorageBlock(info, storage, [this, info, storage, plan]() { + verifySetpointPlan(info, storage, plan); + }); return; } const SunSpecStorageLogic::Write write = plan.writes.at(index); + /* Defence in depth: a Fronius inverter accepts some writes and then ignores + * them without raising a Modbus exception, so the failure is invisible. + * Those registers are refused before anything goes on the wire rather than + * silently pretending to have worked. */ + if (isFroniusChild(info->thing()->parentId()) + && FroniusRules::isWriteForbidden(SunSpecStorageLogic::registerName(write.reg))) { + qCWarning(dcSunSpec()) << "Refusing to write" << SunSpecStorageLogic::registerName(write.reg) + << "on" << info->thing() + << "- this register is ignored by the inverter without raising an exception."; + info->finish(Thing::ThingErrorHardwareFailure); + return; + } + QModbusReply *reply = writeSetpointRegister(storage, write); if (!reply) { qCWarning(dcSunSpec()) << "Could not write" << SunSpecStorageLogic::registerName(write.reg) << "on" << info->thing(); @@ -559,9 +623,95 @@ void IntegrationPluginSunSpec::executeSetpointWrite(ThingActionInfo *info, SunSp }); } +double IntegrationPluginSunSpec::readSetpointRegister(SunSpecStorageModel *storage, SunSpecStorageLogic::Register reg) +{ + switch (reg) { + case SunSpecStorageLogic::RegisterChaGriSet: + return storage->chaGriSet(); + case SunSpecStorageLogic::RegisterStorCtlMod: + return int(storage->storCtlMod()); + case SunSpecStorageLogic::RegisterInWRte: + return storage->inWRte(); + case SunSpecStorageLogic::RegisterOutWRte: + return storage->outWRte(); + case SunSpecStorageLogic::RegisterRvrtTms: + return storage->inOutWRteRvrtTms(); + } + return 0; +} + +/* Reads the model block again and calls the continuation once the fresh values + * are in. The guard timer keeps a lost or unanswered read from stranding the + * action forever; the continuation then runs on stale values, which is why it + * warns. */ +void IntegrationPluginSunSpec::refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function &continuation) +{ + QObject *context = new QObject(info); + QSharedPointer fired = QSharedPointer::create(false); + + auto proceed = [context, fired, continuation](bool timedOut) { + if (*fired) + return; + *fired = true; + if (timedOut) + qCWarning(dcSunSpec()) << "Timed out waiting for a fresh storage block; continuing on the previous values."; + context->deleteLater(); + continuation(); + }; + + connect(storage, &SunSpecModel::blockUpdated, context, [proceed]() { proceed(false); }); + + QTimer *guard = new QTimer(context); + guard->setSingleShot(true); + guard->setInterval(FroniusRules::DefaultTimeoutMilliSeconds * (FroniusRules::DefaultNumberOfRetries + 1)); + connect(guard, &QTimer::timeout, context, [proceed]() { proceed(true); }); + guard->start(); + + storage->readBlockData(); +} + +/* Write, read back, compare. An inverter may accept a write and then ignore it + * without raising a Modbus exception - control not enabled on the device, grid + * charging not allowed, an IP restriction, or another controller overwriting + * the setpoints. An unverified setpoint is an unapplied setpoint. */ +void IntegrationPluginSunSpec::verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan) +{ + bool healthy = true; + + foreach (const SunSpecStorageLogic::Write &write, plan.writes) { + if (!plan.verifyReadback.contains(write.reg)) + continue; + + const double readBack = readSetpointRegister(storage, write.reg); + + /* The rates carry a scale factor of -2, so anything within a hundredth + * is the same value. The enums and the bitfield compare exactly. */ + const double tolerance = SunSpecStorageLogic::isScaledRate(write.reg) ? 0.011 : 0.0001; + if (qAbs(readBack - write.value) > tolerance) { + qCWarning(dcSunSpec()) << "Setpoint not applied on" << info->thing() << ":" + << SunSpecStorageLogic::registerName(write.reg) + << "was written as" << write.value << "but reads back as" << readBack; + healthy = false; + } + } + + info->thing()->setStateValue(sunspecStorageControlHealthyStateTypeId, healthy); + info->finish(Thing::ThingErrorNoError); +} + void IntegrationPluginSunSpec::executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan) { - executeSetpointWrite(info, storage, plan, 0); + if (!plan.rereadScaleFactor) { + executeSetpointWrite(info, storage, plan, 0); + return; + } + + /* Scale factors are not guaranteed static: they may change with firmware + * and at runtime. A factor cached during an earlier poll can put the + * setpoint an order of magnitude off, so refresh it right before writing. */ + refreshStorageBlock(info, storage, [this, info, storage, plan]() { + executeSetpointWrite(info, storage, plan, 0); + }); } Thing *IntegrationPluginSunSpec::getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId) @@ -716,6 +866,9 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) // Note: for some reason, solar edge is using big endian register order instead // of little endian as specified in sunspec and even solar edge documentation connection = new SunSpecConnection(address, port, slaveId, SunSpecDataPoint::ByteOrderBigEndian, this); + } else if (thing->thingClassId() == froniusConnectionThingClassId) { + // No endianness parameter here: the byte order is imposed, not chosen. + connection = new SunSpecConnection(address, port, slaveId, FroniusRules::byteOrder(), this); } else { SunSpecDataPoint::ByteOrder endianness = SunSpecDataPoint::ByteOrderLittleEndian; QString endiannessParam = thing->paramValue("endianness").toString(); @@ -724,8 +877,14 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) connection = new SunSpecConnection(address, port, slaveId, endianness, this); } - connection->setTimeout(configValue(sunSpecPluginTimeoutParamTypeId).toUInt()); - connection->setNumberOfRetries(configValue(sunSpecPluginNumberOfRetriesParamTypeId).toUInt()); + + if (thing->thingClassId() == froniusConnectionThingClassId) { + connection->setTimeout(FroniusRules::DefaultTimeoutMilliSeconds); + connection->setNumberOfRetries(FroniusRules::DefaultNumberOfRetries); + } else { + connection->setTimeout(configValue(sunSpecPluginTimeoutParamTypeId).toUInt()); + connection->setNumberOfRetries(configValue(sunSpecPluginNumberOfRetriesParamTypeId).toUInt()); + } // Reconnect on monitor reachable changed NetworkDeviceMonitor *monitor = m_monitors.value(thing); @@ -979,6 +1138,15 @@ void IntegrationPluginSunSpec::autocreateSunSpecModelThing(const ThingClassId &t params.append(Param(m_manufacturerParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().manufacturerName)); params.append(Param(m_deviceModelParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().modelName)); params.append(Param(m_serialNumberParamTypeIds.value(descriptor.thingClassId()), model->commonModelInfo().serialNumber)); + + /* A storage created under a Fronius connection arms the revert timer by + * default, where the generic vendor leaves it disabled. A setpoint left + * behind by a stopped HEMS would otherwise hold the customer's battery + * indefinitely. */ + if (thingClassId == sunspecStorageThingClassId && isFroniusChild(parentId)) { + params.append(Param(sunspecStorageThingRevertTimeParamTypeId, FroniusRules::DefaultRevertTimeSeconds)); + } + descriptor.setParams(params); emit autoThingsAppeared({descriptor}); } @@ -1236,7 +1404,7 @@ void IntegrationPluginSunSpec::onPluginConfigurationChanged(const ParamTypeId &p if (paramTypeId == sunSpecPluginUpdateIntervalParamTypeId) { qCDebug(dcSunSpec()) << "Update interval has changed" << value.toInt(); if (m_refreshTimer) { - int refreshTime = value.toInt(); + int refreshTime = effectiveUpdateInterval(); m_refreshTimer->stop(); hardwareManager()->pluginTimerManager()->unregisterTimer(m_refreshTimer); m_refreshTimer = hardwareManager()->pluginTimerManager()->registerTimer(refreshTime); diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 968832c..85bcd05 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -34,6 +34,7 @@ #include "sunspecthing.h" #include "sunspecstoragelogic.h" +#include "froniusrules.h" #include "extern-plugininfo.h" #include @@ -109,10 +110,17 @@ private: bool hasManufacturer(const QStringList &manufacturers, const QString &manufacturer); void markThingStatesDisconnected(Thing *thing); + // Fronius + bool isFroniusChild(const ThingId &parentId); + int effectiveUpdateInterval(); + // Storage setpoints void executeSetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan); void executeSetpointWrite(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan, int index); QModbusReply *writeSetpointRegister(SunSpecStorageModel *storage, const SunSpecStorageLogic::Write &write); + double readSetpointRegister(SunSpecStorageModel *storage, SunSpecStorageLogic::Register reg); + void refreshStorageBlock(ThingActionInfo *info, SunSpecStorageModel *storage, const std::function &continuation); + void verifySetpointPlan(ThingActionInfo *info, SunSpecStorageModel *storage, const SunSpecStorageLogic::SetpointPlan &plan); private slots: void onRefreshTimer(); diff --git a/sunspec/integrationpluginsunspec.json b/sunspec/integrationpluginsunspec.json index 3fb302d..30ec4c6 100644 --- a/sunspec/integrationpluginsunspec.json +++ b/sunspec/integrationpluginsunspec.json @@ -1472,6 +1472,14 @@ "writable": true, "displayNameAction": "Set discharging rate" }, + { + "id": "ed738d23-4bea-40e9-a41e-ce6f37f1a3e2", + "name": "controlHealthy", + "displayName": "Control healthy", + "displayNameEvent": "Control healthy changed", + "type": "bool", + "defaultValue": true + }, { "id": "47035ac0-d054-491a-969d-b2aaa9168b0a", "name": "chargeGradient", @@ -1751,6 +1759,91 @@ ] } ] + }, + { + "name": "fronius", + "displayName": "Fronius", + "id": "8504de60-2625-4ad4-8889-623a8d0453ff", + "thingClasses": [ + { + "name": "froniusConnection", + "displayName": "Fronius", + "id": "a4312ee8-ed15-4378-85e2-0372cef7de03", + "createMethods": [ "discovery", "user" ], + "interfaces": [ "gateway", "networkdevice" ], + "providedInterfaces": [ "solarinverter", "energymeter", "energystorage" ], + "discoveryParamTypes": [ + { + "id": "c1d4df93-cc65-4766-a1b3-7c26edf986c0", + "name":"slaveId", + "displayName": "Slave ID", + "type": "int", + "defaultValue": 1 + } + ], + "paramTypes": [ + { + "id": "bc8c1825-000b-401f-90c8-e70547041fca", + "name": "address", + "displayName": "Host address", + "type": "QString", + "inputType": "IPv4Address", + "defaultValue": "" + }, + { + "id": "5882b8a7-2c83-4601-9dd4-e4cc5b5baed7", + "name": "hostName", + "displayName": "Host name", + "type": "QString", + "inputType": "TextLine", + "defaultValue": "" + }, + { + "id": "9f16596f-1695-4112-bdca-df7207b4e30b", + "name":"macAddress", + "displayName": "MAC address", + "type": "QString", + "inputType": "MacAddress", + "defaultValue": "" + }, + { + "id": "70ea61e2-18c3-4f82-a66c-6ed6876d3567", + "name":"port", + "displayName": "Port", + "type": "int", + "defaultValue": 502 + }, + { + "id": "e8876609-cfad-4ebd-b998-d0b1b2dc5434", + "name":"slaveId", + "displayName": "Slave ID", + "type": "int", + "readOnly": true, + "defaultValue": 1 + }, + { + "id": "ad2ad84f-8513-4337-b7cf-fa45a9914a15", + "name":"meterSlaveId", + "displayName": "Smart Meter slave ID", + "type": "int", + "minValue": 0, + "maxValue": 247, + "defaultValue": 200 + } + ], + "stateTypes":[ + { + "id": "d4cf07e1-6b53-47b6-9cab-7c7764023453", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "type": "bool", + "defaultValue": false, + "cached": false + } + ] + } + ] } ] } diff --git a/sunspec/sunspec.pro b/sunspec/sunspec.pro index 6496657..379eccd 100644 --- a/sunspec/sunspec.pro +++ b/sunspec/sunspec.pro @@ -2,6 +2,7 @@ include(../plugins.pri) include(../sunspec.pri) SOURCES += \ + froniusrules.cpp \ integrationpluginsunspec.cpp \ solaredgebattery.cpp \ sunspecdiscovery.cpp \ @@ -9,6 +10,7 @@ SOURCES += \ sunspecthing.cpp HEADERS += \ + froniusrules.h \ integrationpluginsunspec.h \ solaredgebattery.h \ sunspecdiscovery.h \ From 372be1644c286a1bd640ab14266e775896da75e4 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 31 Jul 2026 10:33:49 +0200 Subject: [PATCH 11/24] specs: record acceptance criteria status and remaining gaps --- specs/fronius-sunspec.md | 72 +++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/specs/fronius-sunspec.md b/specs/fronius-sunspec.md index 1ac1494..63612c9 100644 --- a/specs/fronius-sunspec.md +++ b/specs/fronius-sunspec.md @@ -359,20 +359,62 @@ confirmation humaine au moment de l'implémentation. ## Tableau de statut +Suite : `sunspec/tests/`, **38 tests, 38 verts**. + +Build : `qmake6 NYMEA_SUNSPEC_PATH= sunspec/tests/tests.pro && make check`. +Sans `NYMEA_SUNSPEC_PATH`, `sunspec.pri` résout `libnymea-sunspec` par +`pkg-config`, ce qui suppose `libnymea-sunspec-dev` installé système. + | AC | Objet | Test | Statut | |---|---|---|---| -| AC-1 | plage −100…+100 | `test_AC_1_*` | à renseigner | -| AC-2 | lecture `inWRte`/`outWRte` | `test_AC_2_*` | à renseigner | -| AC-3 | `enableDischarging` relu | `test_AC_3_*` | à renseigner | -| AC-4 | `RvrtTms` écrit en dernier | `test_AC_4_*` | à renseigner | -| AC-5 | filtre fabricant | `test_AC_5_*` | à renseigner | -| AC-6 | little-endian | `test_AC_6_*` | à renseigner | -| AC-7 | paramètres `froniusConnection` | `test_AC_7_*` | à renseigner | -| AC-8 | plancher 7 s | `test_AC_8_*` | à renseigner | -| AC-9 | registres interdits | `test_AC_9_*` | à renseigner | -| AC-10 | `revertTime` 300 s | `test_AC_10_*` | à renseigner | -| AC-11 | table mode → registres | `test_AC_11_*` | à renseigner | -| AC-12 | relecture du SF | `test_AC_12_*` | à renseigner | -| AC-13 | relecture + `controlHealthy` | `test_AC_13_*` | à renseigner | -| AC-14 | classement par `IDStr` | `test_AC_14_*` | à renseigner | -| AC-15 | agrégats modèle 160 | `test_AC_15_*` | à renseigner | +| AC-1 | plage −100…+100 | `test_AC_1_*` | vert | +| AC-2 | lecture `inWRte`/`outWRte` | `test_AC_2_*` | vert | +| AC-3 | `enableDischarging` relu | `test_AC_3_*` | vert | +| AC-4 | `RvrtTms` écrit en dernier | `test_AC_4_*` | vert | +| AC-5 | filtre fabricant | `test_AC_5_*` | vert | +| AC-6 | little-endian | `test_AC_6_*` | vert — mais c'est une **inférence** (H-3), pas une mesure | +| AC-7 | paramètres `froniusConnection` | `test_AC_7_*` | vert | +| AC-8 | plancher 7 s | `test_AC_8_*` | vert, avec réserve ci-dessous | +| AC-9 | registres interdits | `test_AC_9_*` | vert | +| AC-10 | `revertTime` 300 s | `test_AC_10_*` | vert | +| AC-11 | table mode → registres | `test_AC_11_*` | vert au niveau du plan ; **aucune action n'expose encore les six modes** | +| AC-12 | relecture du SF | `test_AC_12_*` | vert | +| AC-13 | relecture + `controlHealthy` | `test_AC_13_*` | vert | +| AC-14 | classement par `IDStr` | `test_AC_14_*` | vert ; **non branché** — modèle 160 toujours absent du `switch` (anomalie A5, hors périmètre) | +| AC-15 | agrégats modèle 160 | `test_AC_15_*` | idem AC-14 | + +### Réserves à lever + +- **AC-8** — le minuteur d'interrogation de nymea est **global au plugin**, pas + par connexion. Le plancher est donc appliqué à l'échelle du plugin dès qu'une + connexion `fronius` est configurée : c'est plus strict que l'AC, jamais moins. + Le paramètre `updateInterval` par thing a été **retiré** de + `froniusConnection` : l'architecture du plugin ne peut pas l'honorer par + connexion, et un réglage sans effet serait pire que son absence. +- **AC-11** — la fonction `FroniusRules::planBatteryMode()` produit exactement + la table du contrat, et le plan est exécutable par + `IntegrationPluginSunSpec::executeSetpointPlan()`. Il manque l'action nymea + qui l'appelle : le nommage de cette action et de ses paramètres n'est pas + tranché par le contrat. +- **AC-14 / AC-15** — les fonctions de classement et d'agrégation sont écrites + et testées, mais aucun thing du modèle 160 n'est créé tant que l'anomalie A5 + (`ModelIdMppt` absent du `switch` de `processDiscoveryResult()`) n'est pas + corrigée. A5 est hors du périmètre des quatre correctifs demandés. +- **AC-15.4** — respecté par construction : aucun calcul de production PV par + différence n'a été ajouté. + +### Multi-unit-ID (H-1) + +**Non implémenté.** `froniusConnection` porte bien le paramètre `meterSlaveId` +(AC-7, vert), mais le plugin n'instancie encore qu'une seule `SunSpecConnection` +par thing. Le passage à deux connexions internes touche `m_sunSpecConnections`, +`createConnection()`, `setupThing()`, `processDiscoveryResult()`, +`thingRemoved()`, `postSetupThing()` et `onRefreshTimer()`. + +Deux raisons de ne pas l'avoir fait dans la foulée : la décision D1 n'est pas +validée, et le brief demande explicitement de traiter ce lot **isolément**, +après que le reste soit fonctionnel sur l'onduleur seul. S'y ajoute H-4 : rien +ne dit que l'onduleur accepte deux connexions TCP simultanées, et le +constructeur recommande des requêtes séquentielles. Ce point se mesure au banc ; +le supposer serait exactement le mode de défaillance que le contrat cherche à +éviter. From c2eccd96e5f199d88f3785fb54f453846119dcc4 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 1 Aug 2026 16:18:41 +0200 Subject: [PATCH 12/24] sunspec: add a failing test that sets up a Fronius connection The existing suite only exercises free functions and the plugin JSON, so it cannot see whether a thing class is dispatched at all. A thing class missing from the dispatch in setupThing() falls into the final Q_ASSERT_X: in a debug build the process dies, in a release build the assert is compiled out and setupThing() returns without ever calling info->finish(), leaving the pairing stuck with no message. Split the tests into two suites. The logic one keeps its former scope and its single dependency on libnymea-sunspec. The new one instantiates the plugin behind the real ThingManager of libnymea-core and a faked HardwareManager, so the dispatch of setupThing(), postSetupThing() and thingRemoved() is covered. No network access takes place: the faked monitor is never reachable, so the plugin never attempts a Modbus connection. The four new cases fail: froniusConnection is not dispatched yet. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/tests/logic/logic.pro | 29 ++ sunspec/tests/{ => logic}/tst_sunspec.cpp | 0 sunspec/tests/plugin/plugin.pro | 62 ++++ sunspec/tests/plugin/tst_sunspecplugin.cpp | 388 +++++++++++++++++++++ sunspec/tests/tests.pro | 36 +- 5 files changed, 487 insertions(+), 28 deletions(-) create mode 100644 sunspec/tests/logic/logic.pro rename sunspec/tests/{ => logic}/tst_sunspec.cpp (100%) create mode 100644 sunspec/tests/plugin/plugin.pro create mode 100644 sunspec/tests/plugin/tst_sunspecplugin.cpp diff --git a/sunspec/tests/logic/logic.pro b/sunspec/tests/logic/logic.pro new file mode 100644 index 0000000..153606b --- /dev/null +++ b/sunspec/tests/logic/logic.pro @@ -0,0 +1,29 @@ +include(../../../sunspec.pri) + +QT += testlib +QT -= gui +CONFIG += console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = tst_sunspec + +# Suite de logique pure : ce que le plugin *decide*. Elle ne charge ni nymea ni +# le plugin lui-meme, et ne demande donc que libnymea-sunspec. La suite qui +# instancie reellement le plugin vit dans ../plugin/ et porte ses propres +# dependances. Ce qui est emis sur le bus releve du banc (voir "Non verifiable +# par les tests unitaires" dans specs/fronius-sunspec.md). + +INCLUDEPATH += ../.. + +# Chemin du JSON de definition du plugin, pour les criteres qui portent sur les +# thingClasses et leurs plages de valeurs (AC-1, AC-7, AC-8, AC-10). +DEFINES += PLUGIN_JSON_PATH=\\\"$$PWD/../../integrationpluginsunspec.json\\\" + +SOURCES += \ + tst_sunspec.cpp \ + ../../sunspecstoragelogic.cpp \ + ../../froniusrules.cpp + +HEADERS += \ + ../../sunspecstoragelogic.h \ + ../../froniusrules.h diff --git a/sunspec/tests/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp similarity index 100% rename from sunspec/tests/tst_sunspec.cpp rename to sunspec/tests/logic/tst_sunspec.cpp diff --git a/sunspec/tests/plugin/plugin.pro b/sunspec/tests/plugin/plugin.pro new file mode 100644 index 0000000..a80081b --- /dev/null +++ b/sunspec/tests/plugin/plugin.pro @@ -0,0 +1,62 @@ +include(../../../sunspec.pri) + +QT += testlib network serialbus +QT -= gui +CONFIG += console testcase c++17 +CONFIG -= app_bundle +TEMPLATE = app +TARGET = tst_sunspecplugin + +# Suite qui instancie reellement IntegrationPluginSunSpec, derriere un +# ThingManager reel (celui de libnymea-core) et un HardwareManager double. Elle +# couvre ce que la suite de logique pure ne peut pas voir : l'aiguillage de +# setupThing() / postSetupThing() / thingRemoved() sur la thingClass. +# +# Dependances supplementaires par rapport a ../logic : libnymea1-dev et +# libnymea-core (pkg-config nymea et nymea-core). + +CONFIG += link_pkgconfig +PKGCONFIG += nymea nymea-core + +PLUGIN_DIR = $$PWD/../.. +INCLUDEPATH += $$PLUGIN_DIR + +# Le plugin s'appuie sur les identifiants generes depuis son JSON. On les +# fabrique ici exactement comme le fait plugin.pri pour le plugin lui-meme : +# plugininfo.h (definitions, inclus par integrationpluginsunspec.cpp) et +# extern-plugininfo.h (declarations, inclus par les en-tetes et par le test). +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_sunspecplugin.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/plugin/tst_sunspecplugin.cpp b/sunspec/tests/plugin/tst_sunspecplugin.cpp new file mode 100644 index 0000000..79b74d0 --- /dev/null +++ b/sunspec/tests/plugin/tst_sunspecplugin.cpp @@ -0,0 +1,388 @@ +// 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 . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +/* Suite qui instancie reellement le plugin. + * + * La suite de logique pure (../logic) ne peut voir que ce que le plugin + * *decide* : elle appelle des fonctions libres et lit le JSON. Elle ne peut pas + * voir l'aiguillage, c'est a dire le fait qu'une thingClass soit reconnue par + * setupThing(), postSetupThing() et thingRemoved(). Une thingClass oubliee dans + * l'un de ces trois aiguillages tombe dans un Q_ASSERT_X final : en debug le + * processus meurt, en release l'assert est compile hors du binaire et + * setupThing() rend la main sans jamais appeler info->finish(). L'appairage + * reste alors bloque indefiniment, sans le moindre message. + * + * C'est exactement ce qui est arrive a froniusConnection, et aucun test ne + * pouvait le voir. + * + * Le plugin est monte derriere le vrai ThingManager de libnymea-core - c'est + * lui qui construit les Thing et les ThingSetupInfo, et lui qui appelle + * postSetupThing() - et derriere un HardwareManager double. Aucun acces reseau + * n'a lieu : le moniteur double n'est jamais joignable, donc le plugin ne tente + * jamais de connexion Modbus. + */ + +#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 : c'est l'etat + * dans lequel se trouve une connexion dont l'appareil n'a pas encore ete vu sur + * le reseau. Le plugin doit y repondre, pas s'y bloquer. + * -------------------------------------------------------------------------- */ + +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) {} + + /* registerMonitor() n'est appele que par la branche "connexion" de + * setupThing(). Compter les appels dit donc si le plugin a bien pris cette + * branche, et pas seulement s'il a fini par repondre quelque chose. */ + 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) {} + + /* Le minuteur d'interrogation est arme par postSetupThing(). Le compteur dit + * donc si postSetupThing() a bien traite la connexion. */ + int registerTimerCount = 0; + + PluginTimer *registerTimer(int seconds) override + { + registerTimerCount++; + 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; } + + FakePluginTimerManager *timers() const { return m_pluginTimerManager; } + FakeNetworkDeviceDiscovery *monitors() const { return m_networkDeviceDiscovery; } + +private: + FakePluginTimerManager *m_pluginTimerManager = nullptr; + FakeNetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr; +}; + +/* setMetaData() est protege : le ThingManager le renseigne normalement depuis le + * QPluginLoader. Ici le plugin est instancie en dur, donc le test lui passe la + * meme metadonnee, lue dans le meme JSON. */ +class TestableSunSpecPlugin : public IntegrationPluginSunSpec +{ +public: + using IntegrationPlugin::setMetaData; +}; + +/* -------------------------------------------------------------------------- */ + +class SunSpecPluginTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void cleanupTestCase(); + + void test_F1_stored_fronius_connection_completes_its_setup(); + void test_F1_stored_fronius_connection_is_removed_cleanly(); + void test_F1_added_fronius_connection_is_answered(); + void test_F1_added_fronius_connection_answers_like_the_generic_vendor(); + +private: + QTemporaryDir m_pluginSearchDir; + FakeHardwareManager *m_hardwareManager = nullptr; + ThingManagerImplementation *m_thingManager = nullptr; + + ThingSetupInfo *addConnection(const ThingClassId &thingClassId, const QString &name); +}; + +/* Thing deja configure, ecrit 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 StoredFroniusThingId = ThingId("4e9b1c2d-6f31-4a58-91a7-2b8c0d5e7f14"); + +void SunSpecPluginTest::initTestCase() +{ + /* "nymea-test" deporte les reglages dans /tmp, hors de la configuration + * reelle de l'utilisateur. */ + QCoreApplication::setOrganizationName("nymea-test"); + + NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings); + thingSettings.clear(); + NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins); + pluginSettings.clear(); + + /* Repertoire de recherche vide : le seul plugin en jeu est celui que le test + * enregistre lui-meme, pas ceux installes sur la machine. */ + 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"); + + NymeaSettings settings(NymeaSettings::SettingsRoleThings); + settings.beginGroup("ThingConfig"); + settings.beginGroup(StoredFroniusThingId.toString()); + settings.setValue("thingName", "Fronius"); + settings.setValue("pluginid", metadata.pluginId().toString()); + settings.setValue("thingClassId", froniusConnectionThingClassId.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, sans quoi le ThingManager enregistrerait un logger sur un moteur + * absent. Ces trois cles ecrasent les valeurs suggerees par la thingClass. */ + settings.setValue("loggedStateTypeIds", QStringList()); + settings.setValue("loggedEventTypeIds", QStringList()); + settings.setValue("loggedActionTypeIds", QStringList()); + settings.beginGroup("Params"); + settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); + settings.setValue(froniusConnectionThingPortParamTypeId.toString(), 502); + settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), 1); + settings.endGroup(); + settings.endGroup(); + settings.endGroup(); + settings.sync(); + + m_hardwareManager = new FakeHardwareManager(this); + m_thingManager = new ThingManagerImplementation(m_hardwareManager, nullptr, QLocale(), this); + + TestableSunSpecPlugin *plugin = new TestableSunSpecPlugin(); + plugin->setMetaData(metadata); + m_thingManager->registerStaticPlugin(plugin); + + QVERIFY2(!m_thingManager->findThingClass(froniusConnectionThingClassId).id().isNull(), + "la thingClass froniusConnection n'est pas connue du ThingManager"); +} + +void SunSpecPluginTest::cleanupTestCase() +{ + delete m_thingManager; + m_thingManager = nullptr; +} + +ThingSetupInfo *SunSpecPluginTest::addConnection(const ThingClassId &thingClassId, const QString &name) +{ + const ThingClass thingClass = m_thingManager->findThingClass(thingClassId); + + ParamList params; + params.append(Param(thingClass.paramTypes().findByName("address").id(), QString("192.0.2.1"))); + + return m_thingManager->addConfiguredThing(thingClassId, params, name); +} + +/* Le chemin du demarrage de nymead : la connexion est deja dans les reglages, + * le setup n'est pas un setup initial et doit aboutir sans appareil joignable. + * C'est le seul cas ou l'on peut verifier que le succes est bien annonce. + * + * Avant correction, setupThing() ne reconnait pas froniusConnection, ne finit + * jamais, et le thing reste en ThingSetupStatusInProgress pour toujours. */ +void SunSpecPluginTest::test_F1_stored_fronius_connection_completes_its_setup() +{ + Thing *thing = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(StoredFroniusThingId)) != nullptr, 2000); + QCOMPARE(thing->thingClassId(), froniusConnectionThingClassId); + + QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 2000); + QCOMPARE(thing->setupError(), Thing::ThingErrorNoError); + + /* La branche connexion de setupThing() a bien ete prise... */ + QCOMPARE(m_hardwareManager->monitors()->registerMonitorCount, 1); + /* ...et postSetupThing() a arme le minuteur d'interrogation. */ + QCOMPARE(m_hardwareManager->timers()->registerTimerCount, 1); +} + +/* thingRemoved() est indexe sur m_sunSpecConnections, pas sur la thingClass : il + * n'a donc rien de specifique a faire pour Fronius. Verifie plutot que suppose. */ +void SunSpecPluginTest::test_F1_stored_fronius_connection_is_removed_cleanly() +{ + QVERIFY(m_thingManager->findConfiguredThing(StoredFroniusThingId)); + + const int unregisteredBefore = m_hardwareManager->monitors()->unregisterMonitorCount; + + QCOMPARE(m_thingManager->removeConfiguredThing(StoredFroniusThingId), Thing::ThingErrorNoError); + + QVERIFY(!m_thingManager->findConfiguredThing(StoredFroniusThingId)); + QCOMPARE(m_hardwareManager->monitors()->unregisterMonitorCount, unregisteredBefore + 1); +} + +/* Le chemin de l'appairage. Sans adresse connue, un setup initial se termine en + * ThingErrorHardwareFailure : c'est un refus explicite, pas un blocage. Avant + * correction, aucune reponse n'arrive jamais. */ +void SunSpecPluginTest::test_F1_added_fronius_connection_is_answered() +{ + const int monitorsBefore = m_hardwareManager->monitors()->registerMonitorCount; + + ThingSetupInfo *info = addConnection(froniusConnectionThingClassId, "Fronius appaire"); + QVERIFY(info); + + if (!info->isFinished()) { + QSignalSpy finished(info, &ThingSetupInfo::finished); + QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish()"); + } + + QCOMPARE(m_hardwareManager->monitors()->registerMonitorCount, monitorsBefore + 1); + QCOMPARE(info->status(), Thing::ThingErrorHardwareFailure); + QVERIFY(!info->displayMessage().isEmpty()); +} + +/* Le vendor generique passe par exactement le meme chemin. Si les deux reponses + * divergent, c'est que froniusConnection a ete branche autrement, pas comme les + * vendors deja fonctionnels. */ +void SunSpecPluginTest::test_F1_added_fronius_connection_answers_like_the_generic_vendor() +{ + ThingSetupInfo *generic = addConnection(sunspecConnectionThingClassId, "SunSpec appaire"); + QVERIFY(generic); + if (!generic->isFinished()) { + QSignalSpy finished(generic, &ThingSetupInfo::finished); + QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish() pour sunspecConnection"); + } + + ThingSetupInfo *fronius = addConnection(froniusConnectionThingClassId, "Fronius appaire 2"); + QVERIFY(fronius); + if (!fronius->isFinished()) { + QSignalSpy finished(fronius, &ThingSetupInfo::finished); + QVERIFY2(finished.wait(2000), "setupThing() n'a jamais appele info->finish() pour froniusConnection"); + } + + QCOMPARE(fronius->status(), generic->status()); + QCOMPARE(fronius->displayMessage(), generic->displayMessage()); +} + +QTEST_MAIN(SunSpecPluginTest) +#include "tst_sunspecplugin.moc" diff --git a/sunspec/tests/tests.pro b/sunspec/tests/tests.pro index a8981ef..c944294 100644 --- a/sunspec/tests/tests.pro +++ b/sunspec/tests/tests.pro @@ -1,30 +1,10 @@ -include(../../sunspec.pri) +TEMPLATE = subdirs -QT += testlib -QT -= gui -CONFIG += console testcase -CONFIG -= app_bundle -TEMPLATE = app -TARGET = tst_sunspec +# logic : logique pure du plugin, sans nymea. Ne demande que libnymea-sunspec. +# plugin : instancie reellement IntegrationPluginSunSpec derriere un +# ThingManager et un HardwareManager doubles. Demande en plus +# libnymea1-dev et libnymea-core (pkg-config nymea, nymea-core). -# Les tests pilotent la logique pure du plugin. Ils ne chargent pas le plugin -# nymea lui-meme : cela demanderait un ThingManager, un PluginTimer et un -# NetworkDeviceDiscovery, c'est-a-dire un nymead complet. Ce qui est teste ici, -# c'est ce que le plugin *decide* ; ce qu'il emet reellement sur le bus releve -# du banc (voir "Non verifiable par les tests unitaires" dans -# specs/fronius-sunspec.md). - -INCLUDEPATH += .. - -# Chemin du JSON de definition du plugin, pour les criteres qui portent sur les -# thingClasses et leurs plages de valeurs (AC-1, AC-7, AC-8, AC-10). -DEFINES += PLUGIN_JSON_PATH=\\\"$$PWD/../integrationpluginsunspec.json\\\" - -SOURCES += \ - tst_sunspec.cpp \ - ../sunspecstoragelogic.cpp \ - ../froniusrules.cpp - -HEADERS += \ - ../sunspecstoragelogic.h \ - ../froniusrules.h +SUBDIRS += \ + logic \ + plugin From 3802807be7697c9546ab75e5165511103c39c0c4 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sat, 1 Aug 2026 16:20:09 +0200 Subject: [PATCH 13/24] sunspec: dispatch froniusConnection in setupThing() setupThing() only knew about sunspecConnection and solarEdgeConnection, so a froniusConnection thing fell into the final Q_ASSERT_X. In a release build the assert is compiled out, setupThing() returns without calling info->finish(), and the pairing hangs until the setup times out - with no message at all. In a debug build nymead dies on the assert instead. Take the same branch as the two vendors that already work: createConnection() already reads the slave ID and picks the byte order for froniusConnection, so no new logic is needed. postSetupThing() and thingRemoved() are unchanged on purpose. The former only special cases solarEdgeConnection, for the battery search a Fronius must not run, and registers the refresh timer for every thing. The latter is keyed on m_sunSpecConnections, which createConnection() now populates for Fronius as well. Makes the four cases of tst_sunspecplugin pass. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/integrationpluginsunspec.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 133cedd..6197a33 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -247,7 +247,8 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) qCDebug(dcSunSpec()) << thing->params(); if (thing->thingClassId() == sunspecConnectionThingClassId || - thing->thingClassId() == solarEdgeConnectionThingClassId) { + thing->thingClassId() == solarEdgeConnectionThingClassId || + thing->thingClassId() == froniusConnectionThingClassId) { // Handle reconfigure if (m_sunSpecConnections.contains(thing->id())) { From e7b751673d679ae503db3c5c25a694dc53d4ea67 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 08:50:18 +0200 Subject: [PATCH 14/24] sunspec: record why meterSlaveId cannot be marked advanced AC-7 asks for the meterSlaveId parameter to be advanced. That property does not exist on nymea 1.15.2 param types: the metadata verifier rejects unknown fields, so adding it makes the whole plugin fail to load rather than merely hiding a parameter. Measured by loading the plugin JSON, with and without the flag, into a PluginMetadata linked against the installed libnymea, rather than read from an upstream tree that may differ from the package. Also records the three other divergences the multi unit ID work runs into, two of which predate it. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/DIVERGENCES.md | 113 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 sunspec/DIVERGENCES.md diff --git a/sunspec/DIVERGENCES.md b/sunspec/DIVERGENCES.md new file mode 100644 index 0000000..bdfdbd6 --- /dev/null +++ b/sunspec/DIVERGENCES.md @@ -0,0 +1,113 @@ +# Divergences — vendor Fronius du plugin SunSpec + +Écarts assumés entre ce que demandent `specs/fronius-sunspec.md` et le contrat +d'une part, et ce que le code fait d'autre part. Chaque entrée dit **pourquoi** +et **comment la lever**. + +Deux étiquettes : + +- **MESURÉ** — l'écart repose sur une observation faite sur du matériel ou sur + le code de la plateforme, reproductible. +- **INFÉRÉ** — l'écart repose sur un raisonnement, faute de donnée. À reprendre + dès qu'une mesure existe. + +--- + +## D-1 — `meterSlaveId` n'est pas marqué `advanced` (MESURÉ) + +**Ce que demande AC-7.** Le paramètre `meterSlaveId` de la thingClass +`froniusConnection` doit être `advanced`. + +**Ce que fait le code.** Rien : le paramètre n'a pas ce drapeau. + +**Pourquoi.** `advanced` n'est pas une propriété de paramType dans nymea 1.15.2. +`ParamType::typeProperties()` énumère exactement `id`, `name`, `displayName`, +`type`, `defaultValue`, `inputType`, `unit`, `minValue`, `maxValue`, `stepSize`, +`allowedValues`, `readOnly`. Le vérificateur de métadonnée refuse tout champ +inconnu : + +``` +Param type "meterSlaveId" has unknown fields: "advanced" +``` + +et `PluginMetadata::isValid()` passe à `false`. Ce n'est pas un paramètre mal +affiché : **le plugin ne se charge plus du tout**, et les erreurs suivantes en +cascade (le paramType est abandonné, donc `froniusConnection` ne satisfait plus +l'interface `networkdevice`) rendent le diagnostic trompeur. + +Vérifié en chargeant le JSON, avec et sans le drapeau, dans un +`PluginMetadata` lié à la `libnymea` installée — pas seulement lu dans une +source amont qui pourrait diverger du paquet. + +**Comment lever.** Rien à faire côté plugin. Il faut soit que nymea ajoute la +propriété, soit que AC-7 soit corrigé. Un test de garde +(`test_F3_meter_slave_id_is_not_marked_advanced`) empêche qu'on ajoute le +drapeau sans refaire la mesure. + +--- + +## D-2 — délai de garde du balayage des enfants orphelins (INFÉRÉ) + +**Contexte.** `processDiscoveryResult()` fait disparaître les things enfants +dont le modèle n'est plus annoncé. Avec deux connexions sous le même parent, +chacune ne voit que la moitié du tableau : balayer sur cette moitié ferait +disparaître les enfants de l'autre à chaque découverte. Le balayage attend donc +que **toutes** les connexions du parent aient réussi une découverte, puis +confronte les enfants à l'union de leurs modèles. + +**L'écart.** Une connexion dont la découverte n'aboutit jamais bloquerait le +balayage pour toujours. Un délai de garde le débloque : +`OrphanSweepGraceSeconds = 600` (10 minutes). + +**Pourquoi cette valeur, et pourquoi elle est INFÉRÉE.** Aucune donnée ne la +fixe. Le choix est asymétrique et volontairement paresseux : un balayage trop +tardif laisse traîner un thing périmé affiché déconnecté, un balayage trop +précoce **supprime un thing de l'utilisateur**. À conséquences inégales, +réglage prudent. 10 minutes est très au-delà de tout cycle de découverte +observé, sans être infini. + +**Comment lever.** Mesurer, sur le banc puis sur matériel, le temps que met une +découverte SunSpec à échouer définitivement sur un unit ID qui ne répond pas +(timeout × retries × nombre de registres de base balayés). Le délai de garde +doit valoir quelques fois cette durée, pas dix minutes arbitraires. La valeur +est un seul `const` dans `integrationpluginsunspec.cpp` ; la changer n'a +d'effet sur rien d'autre. + +--- + +## D-3 — la configuration du plugin écrase les défauts de cadence Fronius (MESURÉ, préexistant) + +**Contexte.** `createConnection()` impose `timeout` et `numberOfRetries` du +vendor Fronius (AC-8) à la création. Mais `onPluginConfigurationChanged()` +réapplique ensuite les valeurs globales du plugin à **toutes** les connexions, +Fronius comprise. + +**L'écart.** Il est antérieur à F3 et affecte déjà la connexion onduleur seule. +F3 ne le corrige pas ; il l'étend symétriquement à la connexion compteur, pour +que les deux connexions d'un même thing se comportent identiquement. Le +plancher d'intervalle d'interrogation d'AC-8, lui, reste appliqué : il vit dans +`effectiveUpdateInterval()`, pas ici. + +**Comment lever.** Hors périmètre de F3 : cela demande de décider si les +réglages globaux du plugin doivent pouvoir passer outre un défaut vendor, ce que +ni le contrat ni la spec ne tranchent. + +--- + +## D-4 — connexion détruite par `ThingSetupInfo::aborted` et laissée dans la table (MESURÉ, préexistant, amont) + +**Contexte.** `setupThing()` relie `ThingSetupInfo::aborted` à +`SunSpecConnection::deleteLater()`, alors que `createConnection()` a déjà inséré +la connexion dans `m_sunSpecConnections`. Un setup avorté laisse donc un +pointeur pendant dans la table ; le setup suivant du même thing prend la branche +« reconfiguration » et appelle `deleteLater()` sur un objet déjà détruit. + +**L'écart.** F3 ne corrige pas ce chemin pour la connexion primaire : il est +commun aux trois vendors, y compris `solarEdge` dont aucun test ne couvre le +comportement, et le périmètre du lot est explicitement « generic et +solarEdgeConnection ne changent pas de comportement ». La connexion **compteur** +ajoutée par F3, elle, se retire proprement de sa table à la destruction. + +**Comment lever.** Relier `QObject::destroyed` au retrait de la table pour les +deux connexions, dans un lot qui assume de toucher le chemin commun, avec une +couverture de test pour `solarEdge`. From 4f2076f504aa0622b9aef7c56c7335bca8904590 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 08:50:25 +0200 Subject: [PATCH 15/24] sunspec: decide whether a Fronius meter connection is wanted A GEN24 answers for the inverter on one unit ID and for the Smart Meter on another, behind the same host and port. Isolate the decision to open that second connection as a pure function, so it can be pinned down without nymea, and leave the logging of the refusal to the caller. Refuses a meter slave ID out of the 0..247 range, treats 0 as "no meter on this connection" as documented, and refuses a meter slave ID equal to the inverter one: a second socket on the same unit ID would rediscover the very same models and duplicate every child thing. That last case is reachable, since the discovery writes the scanned slave ID into the thing parameter. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/froniusrules.cpp | 17 ++++++ sunspec/froniusrules.h | 16 ++++++ sunspec/tests/logic/tst_sunspec.cpp | 88 +++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/sunspec/froniusrules.cpp b/sunspec/froniusrules.cpp index d655b91..bd0bb50 100644 --- a/sunspec/froniusrules.cpp +++ b/sunspec/froniusrules.cpp @@ -40,6 +40,23 @@ SunSpecDataPoint::ByteOrder byteOrder() return SunSpecDataPoint::ByteOrderLittleEndian; } +MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId) +{ + if (meterSlaveId == 0) + return MeterConnectionDisabled; + + if (meterSlaveId < 0 || meterSlaveId > MaximumSlaveId) + return MeterConnectionInvalidSlaveId; + + /* Not a theoretical case: the slave ID of the thing is written by the + * discovery from the slave ID it was asked to scan, so an inverter found + * with a discovery slave ID of 200 ends up with both values at 200. */ + if (meterSlaveId == inverterSlaveId) + return MeterConnectionCollidesWithInverter; + + return MeterConnectionCreate; +} + int clampUpdateInterval(int requestedSeconds) { return qMax(requestedSeconds, MinimumUpdateIntervalSeconds); diff --git a/sunspec/froniusrules.h b/sunspec/froniusrules.h index ced9824..f98fd4d 100644 --- a/sunspec/froniusrules.h +++ b/sunspec/froniusrules.h @@ -63,6 +63,22 @@ const int DefaultRevertTimeSeconds = 300; * connection". */ const int InverterSlaveId = 1; const int DefaultMeterSlaveId = 200; +const int MaximumSlaveId = 247; + +/* Whether a second SunSpecConnection should be opened for the Smart Meter, and + * why not when it should not. The caller logs the reason; keeping it out of + * this function is what makes the decision testable without nymea. */ +enum MeterConnectionDecision { + MeterConnectionCreate, + /* 0 is the documented way of saying "no meter behind this inverter". */ + MeterConnectionDisabled, + /* A second socket on the unit ID already served by the inverter would + * rediscover the very same models and duplicate every child thing. */ + MeterConnectionCollidesWithInverter, + MeterConnectionInvalidSlaveId +}; + +MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId); bool matchesManufacturer(const QStringList &manufacturers); diff --git a/sunspec/tests/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp index fd218c8..04fe1c1 100644 --- a/sunspec/tests/logic/tst_sunspec.cpp +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -161,6 +161,14 @@ private slots: void test_AC_15_battery_power_is_positive_when_discharging(); void test_AC_15_battery_power_is_negative_when_charging(); + // F3 - multi unit ID + void test_F3_meter_slave_id_zero_disables_the_meter_connection(); + void test_F3_meter_slave_id_out_of_range_is_refused(); + void test_F3_meter_slave_id_equal_to_the_inverter_is_refused(); + void test_F3_meter_slave_id_creates_the_connection(); + void test_F3_meter_slave_id_is_not_marked_advanced(); + void test_F3_generic_vendors_have_no_meter_slave_id(); + private: QJsonObject m_pluginJson; @@ -782,6 +790,86 @@ void SunSpecFroniusTest::test_AC_15_battery_power_is_negative_when_charging() QCOMPARE(batteryPower(readings), -600.0); } +// --------------------------------------------------------------------------- +// F3 - multi unit ID +// +// Le GEN24 repond pour l'onduleur sur un unit ID et pour le Smart Meter sur un +// autre, derriere le meme host:port. La decision d'ouvrir ou non la seconde +// connexion est isolee ici pour etre testable sans nymea ; le plugin se contente +// de journaliser le motif du refus. +// --------------------------------------------------------------------------- + +void SunSpecFroniusTest::test_F3_meter_slave_id_zero_disables_the_meter_connection() +{ + using namespace FroniusRules; + + // 0 est la facon documentee de dire "pas de compteur derriere cet onduleur". + QCOMPARE(decideMeterConnection(0, InverterSlaveId), MeterConnectionDisabled); +} + +void SunSpecFroniusTest::test_F3_meter_slave_id_out_of_range_is_refused() +{ + using namespace FroniusRules; + + QCOMPARE(decideMeterConnection(248, InverterSlaveId), MeterConnectionInvalidSlaveId); + QCOMPARE(decideMeterConnection(-1, InverterSlaveId), MeterConnectionInvalidSlaveId); + + // La borne haute elle-meme reste valide. + QCOMPARE(decideMeterConnection(MaximumSlaveId, InverterSlaveId), MeterConnectionCreate); +} + +void SunSpecFroniusTest::test_F3_meter_slave_id_equal_to_the_inverter_is_refused() +{ + using namespace FroniusRules; + + // Une seconde socket sur l'unit ID deja servi par l'onduleur redecouvrirait + // exactement les memes modeles et dupliquerait chaque thing enfant. Le cas + // n'est pas theorique : la decouverte ecrit dans le parametre slaveId du + // thing l'unit ID qu'on lui a demande de scanner. + QCOMPARE(decideMeterConnection(1, 1), MeterConnectionCollidesWithInverter); + QCOMPARE(decideMeterConnection(200, 200), MeterConnectionCollidesWithInverter); +} + +void SunSpecFroniusTest::test_F3_meter_slave_id_creates_the_connection() +{ + using namespace FroniusRules; + + QCOMPARE(decideMeterConnection(DefaultMeterSlaveId, InverterSlaveId), MeterConnectionCreate); + QCOMPARE(DefaultMeterSlaveId, 200); + + // Compteurs secondaires. + QCOMPARE(decideMeterConnection(201, InverterSlaveId), MeterConnectionCreate); + QCOMPARE(decideMeterConnection(202, InverterSlaveId), MeterConnectionCreate); +} + +/* AC-7 demande meterSlaveId en "advanced". Cette propriete n'existe pas sur les + * paramTypes de nymea 1.15.2 : ParamType::typeProperties() ne la connait pas, et + * le verificateur de metadonnee rejette tout champ inconnu. Une metadonnee + * rejetee, ce n'est pas un parametre mal affiche, c'est un plugin qui ne se + * charge plus du tout. + * + * Ce test n'entérine pas l'ecart : il empeche qu'on le "corrige" sans mesurer. + * Voir sunspec/DIVERGENCES.md. */ +void SunSpecFroniusTest::test_F3_meter_slave_id_is_not_marked_advanced() +{ + const QJsonObject connection = thingClassByName("froniusConnection"); + const QJsonObject meterSlaveId = paramByName(connection, "meterSlaveId"); + QVERIFY(!meterSlaveId.isEmpty()); + + QVERIFY2(!meterSlaveId.contains("advanced"), + "\"advanced\" n'est pas une propriete de paramType connue de nymea 1.15.2 : " + "l'ajouter rend la metadonnee du plugin invalide et le plugin ne se charge plus. " + "Voir sunspec/DIVERGENCES.md."); +} + +void SunSpecFroniusTest::test_F3_generic_vendors_have_no_meter_slave_id() +{ + // Le multi unit ID est une particularite Fronius. Les vendors generique et + // solarEdge gardent exactement une connexion par thing. + QVERIFY(paramByName(thingClassByName("sunspecConnection"), "meterSlaveId").isEmpty()); + QVERIFY(paramByName(thingClassByName("solarEdgeConnection"), "meterSlaveId").isEmpty()); +} + QTEST_MAIN(SunSpecFroniusTest) #include "tst_sunspec.moc" From 0954f66725337b5026cf518b3a0f925070c9c20a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 08:53:28 +0200 Subject: [PATCH 16/24] sunspec: let createConnection() serve a secondary connection Takes the slave ID as an argument instead of reading it from the thing, and takes whether it is the primary connection of that thing. Only the primary one follows the network monitor, writes the connected state of the thing and lands in m_sunSpecConnections; a secondary one lands in a hash of its own. Child things are updated per connection rather than per parent, resolved from the model each child is bound to. With a single connection that is every child of the parent, so nothing changes here yet. No caller creates a secondary connection at this point: behaviour is unchanged for all three vendors. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/integrationpluginsunspec.cpp | 128 +++++++++++++++++++++------ sunspec/integrationpluginsunspec.h | 38 +++++++- 2 files changed, 140 insertions(+), 26 deletions(-) diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index 6197a33..09771bd 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -286,7 +286,8 @@ void IntegrationPluginSunSpec::setupThing(ThingSetupInfo *info) } }); - SunSpecConnection *connection = createConnection(info->thing()); + uint slaveId = thing->paramValue(m_connectionSlaveIdParamTypeIds.value(thing->thingClassId())).toUInt(); + SunSpecConnection *connection = createConnection(info->thing(), slaveId, true); connect(info, &ThingSetupInfo::aborted, connection, &SunSpecConnection::deleteLater); // If this is the first setup, we must connect successfully fo finishing the setup, otherwise we let the monitor do his work @@ -851,15 +852,22 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne } -SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) +/* Creates one SunSpec connection for a thing. + * + * A thing has exactly one primary connection, and a Fronius connection may have + * a second one towards its Smart Meter on another unit ID. Everything the two + * share - byte order, timeout, retries, rediscovery on reconnect - lives here; + * everything that belongs to the thing as a whole rather than to one of its + * connections is guarded by isPrimary. */ +SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing, uint slaveId, bool isPrimary) { QHostAddress address = m_monitors.value(thing)->networkDeviceInfo().address(); int port = thing->paramValue(m_connectionPortParamTypeIds.value(thing->thingClassId())).toInt(); - int slaveId = thing->paramValue(m_connectionSlaveIdParamTypeIds.value(thing->thingClassId())).toInt(); - if (m_sunSpecConnections.contains(thing->id())) { - qCDebug(dcSunSpec()) << "Reconfigure SunSpec connection" << thing; - m_sunSpecConnections.take(thing->id())->deleteLater(); + QHash &connections = isPrimary ? m_sunSpecConnections : m_meterConnections; + if (connections.contains(thing->id())) { + qCDebug(dcSunSpec()) << "Reconfigure SunSpec connection" << thing << "on slave ID" << slaveId; + connections.take(thing->id())->deleteLater(); } SunSpecConnection *connection = nullptr; @@ -887,31 +895,41 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) connection->setNumberOfRetries(configValue(sunSpecPluginNumberOfRetriesParamTypeId).toUInt()); } - // Reconnect on monitor reachable changed - NetworkDeviceMonitor *monitor = m_monitors.value(thing); - connect(monitor, &NetworkDeviceMonitor::reachableChanged, thing, [=](bool reachable){ - qCDebug(dcSunSpec()) << "Network device monitor reachable changed for" << thing->name() << reachable; - if (!thing->setupComplete()) - return; + /* Reconnect on monitor reachable changed. + * + * Only the primary connection follows the monitor. The meter one is driven + * by the primary's discovery instead, so that the two never handshake at the + * same time towards the same inverter. */ + if (isPrimary) { + NetworkDeviceMonitor *monitor = m_monitors.value(thing); + connect(monitor, &NetworkDeviceMonitor::reachableChanged, thing, [=](bool reachable){ + qCDebug(dcSunSpec()) << "Network device monitor reachable changed for" << thing->name() << reachable; + if (!thing->setupComplete()) + return; - if (reachable && !thing->stateValue("connected").toBool()) { - qCDebug(dcSunSpec()) << "The monitor is reachable. Set the host address to" << monitor->networkDeviceInfo().address() << "and start connecting..."; - connection->setHostAddress(monitor->networkDeviceInfo().address()); - connection->connectDevice(); - } - }); + if (reachable && !thing->stateValue("connected").toBool()) { + qCDebug(dcSunSpec()) << "The monitor is reachable. Set the host address to" << monitor->networkDeviceInfo().address() << "and start connecting..."; + connection->setHostAddress(monitor->networkDeviceInfo().address()); + connection->connectDevice(); + } + }); + } - m_sunSpecConnections.insert(thing->id(), connection); + connections.insert(thing->id(), connection); // Update all child things connected states for this connection - connect(connection, &SunSpecConnection::connectedChanged, thing, [this, connection, thing] (bool connected) { + connect(connection, &SunSpecConnection::connectedChanged, thing, [this, connection, thing, isPrimary] (bool connected) { if (connected) { qCDebug(dcSunSpec()) << connection << "connected"; } else { qCWarning(dcSunSpec()) << connection << "disconnected"; } - thing->setStateValue("connected", connected); + /* The connected state of the thing follows the primary connection alone. + * A Smart Meter that drops must not mark the inverter unreachable, nor + * blank the data it is still delivering. */ + if (isPrimary) + thing->setStateValue("connected", connected); if (connected) { if (thing->setupComplete()) { @@ -919,8 +937,8 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) connection->startDiscovery(); } } - // Update connected state of child things - foreach (Thing *child, myThings().filterByParentId(thing->id())) { + // Update connected state of the child things of this connection + foreach (Thing *child, childThingsOn(thing, connection)) { if (connected) { child->setStateValue("connected", true); } else { @@ -932,15 +950,75 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing) connect(connection, &SunSpecConnection::discoveryFinished, thing, [this, connection, thing] (bool success) { if (success) { qCDebug(dcSunSpec()) << "Discovery finished successfully of" << connection; + m_discoveredConnections.insert(connection); processDiscoveryResult(thing, connection); } else { qCWarning(dcSunSpec()) << "Discovery finished with errors on" << connection; } }); + /* m_discoveredConnections is keyed on the pointer, so it must not outlive + * it. The entry is only ever compared, never dereferenced. */ + connect(connection, &QObject::destroyed, this, [this](QObject *object) { + m_discoveredConnections.remove(static_cast(object)); + }); + return connection; } +QList IntegrationPluginSunSpec::connectionsFor(const ThingId &parentId) const +{ + QList connections; + + SunSpecConnection *primary = m_sunSpecConnections.value(parentId); + if (primary) + connections.append(primary); + + SunSpecConnection *meter = m_meterConnections.value(parentId); + if (meter) + connections.append(meter); + + return connections; +} + +QList IntegrationPluginSunSpec::allConnections() const +{ + QList connections = m_sunSpecConnections.values(); + connections.append(m_meterConnections.values()); + return connections; +} + +SunSpecConnection *IntegrationPluginSunSpec::connectionForChild(Thing *child) const +{ + if (m_sunSpecInverters.contains(child)) + return m_sunSpecInverters.value(child)->connection(); + + if (m_sunSpecMeters.contains(child)) + return m_sunSpecMeters.value(child)->connection(); + + if (m_sunSpecStorages.contains(child)) + return m_sunSpecStorages.value(child)->connection(); + + if (m_sunSpecThings.contains(child)) + return m_sunSpecThings.value(child)->connection(); + + return nullptr; +} + +QList IntegrationPluginSunSpec::childThingsOn(Thing *parent, SunSpecConnection *connection) const +{ + const bool isPrimary = (m_sunSpecConnections.value(parent->id()) == connection); + + QList children; + foreach (Thing *child, myThings().filterByParentId(parent->id())) { + SunSpecConnection *childConnection = connectionForChild(child); + if (childConnection == connection || (!childConnection && isPrimary)) + children.append(child); + } + + return children; +} + void IntegrationPluginSunSpec::setupSolarEdgeBattery(ThingSetupInfo *info) { Thing *thing = info->thing(); @@ -1414,12 +1492,12 @@ void IntegrationPluginSunSpec::onPluginConfigurationChanged(const ParamTypeId &p } } else if (paramTypeId == sunSpecPluginNumberOfRetriesParamTypeId) { qCDebug(dcSunSpec()) << "Updating number of retries" << value.toUInt(); - foreach (SunSpecConnection *connection, m_sunSpecConnections) { + foreach (SunSpecConnection *connection, allConnections()) { connection->setNumberOfRetries(value.toUInt()); } } else if (paramTypeId == sunSpecPluginTimeoutParamTypeId) { qCDebug(dcSunSpec()) << "Updating timeout" << value.toUInt() << "[ms]"; - foreach (SunSpecConnection *connection, m_sunSpecConnections) { + foreach (SunSpecConnection *connection, allConnections()) { connection->setTimeout(value.toUInt()); } } else { diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 85bcd05..1a75316 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -76,20 +76,56 @@ private: QHash m_monitors; + /* The primary connection of a thing: the one that carries its identity, its + * connected state and its timeout and retry settings. Every vendor has + * exactly one. */ QHash m_sunSpecConnections; + + /* Second, optional connection towards the Smart Meter of a Fronius GEN24, + * on the same host and port as the primary one but on another unit ID. + * Indexed on the same parent ThingId. + * + * Kept apart from m_sunSpecConnections rather than turning that one into a + * hash of lists: this way not a single call site of the generic and + * solarEdge vendors changes, including the reverse lookup + * m_sunSpecConnections.key() the SolarEdge battery search relies on. */ + QHash m_meterConnections; + QHash m_sunSpecThings; QHash m_sunSpecInverters; QHash m_sunSpecMeters; QHash m_sunSpecStorages; + /* Connections having completed at least one successful discovery. The + * orphan sweep needs to know whether it is looking at the whole picture or + * at half of it. Entries are removed when the connection is destroyed. */ + QSet m_discoveredConnections; + Thing *getThingForSunSpecModel(uint modelId, uint modbusAddress, const ThingId &parentId); bool sunspecThingAlreadyAdded(uint modelId, uint modbusAddress, const ThingId &parentId); void processDiscoveryResult(Thing *thing, SunSpecConnection *connection); // SunSpec things - SunSpecConnection *createConnection(Thing *thing); + SunSpecConnection *createConnection(Thing *thing, uint slaveId, bool isPrimary); + + /* Every connection of a parent thing, primary first. One entry for the + * generic and solarEdge vendors, one or two for Fronius. */ + QList connectionsFor(const ThingId &parentId) const; + QList allConnections() const; + + /* The connection a child thing reads its data from, or null while it is not + * bound to a model yet. Resolved from the model the child is bound to + * rather than from a persisted unit ID: a child stores only its model ID + * and start register, and the two connections of a Fronius thing never + * announce the same model. */ + SunSpecConnection *connectionForChild(Thing *child) 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. */ + QList childThingsOn(Thing *parent, SunSpecConnection *connection) const; // Custom types void setupSolarEdgeBattery(ThingSetupInfo *info); From a59854471244f2ee39a08faaddae5027ed140be8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:00:27 +0200 Subject: [PATCH 17/24] 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 From e1e91a19c3d8422d6af41871659a9de010caedc5 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:02:35 +0200 Subject: [PATCH 18/24] sunspec: sweep orphan children against every connection at once processDiscoveryResult() makes child things disappear when their model is no longer announced. It used to confront them with the models of the one connection that had just finished discovering, which is only half of the picture on a thing carrying two: the inverter discovery would delete the meter child, and the meter discovery the inverter and storage ones, on every single reconnect. The sweep now waits for every connection of the parent to have reported a successful discovery, then confronts the children with the union of their models. A grace period releases it if a connection never reports, so a hung discovery cannot freeze the cleanup for good. That grace period is inferred, not measured: it is set generously because sweeping too late leaves a stale child showing as disconnected, while sweeping too early deletes a thing of the user. Recorded as D-2 in DIVERGENCES.md with what to measure to replace it. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/integrationpluginsunspec.cpp | 68 +++++++++++++++++++++++++++- sunspec/integrationpluginsunspec.h | 9 ++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index df2ea08..e033fc6 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -56,6 +56,15 @@ #include +/* How long the orphan sweep waits for a connection that has not reported yet + * before going ahead without it. + * + * INFERRED, no measurement backs this value: see D-2 in DIVERGENCES.md. It is + * deliberately generous, because the two failure modes are not comparable. + * Sweeping too late leaves a stale child thing showing as disconnected; + * sweeping too early deletes a thing of the user. */ +static const int OrphanSweepGraceSeconds = 600; + IntegrationPluginSunSpec::IntegrationPluginSunSpec() { @@ -840,6 +849,20 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne if (connection->models().isEmpty()) return; + /* With more than one connection under the same parent, one connection only + * ever sees half of the picture. Sweeping on that half would make the + * children of the other half disappear on every single discovery. So the + * sweep waits for every connection of this parent to have reported, and + * confronts the children with the union of their models. */ + if (!orphanSweepAllowed(thing)) + return; + + QList knownModels; + foreach (SunSpecConnection *parentConnection, connectionsFor(thing->id())) { + if (m_discoveredConnections.contains(parentConnection)) + knownModels.append(parentConnection->models()); + } + foreach (Thing *child, myThings().filterByParentId(thing->id())) { if (!m_modelIdParamTypeIds.contains(child->thingClassId()) || !m_modbusAddressParamTypeIds.contains(child->thingClassId())) continue; @@ -848,7 +871,7 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne uint childModbusAddress = child->paramValue(m_modbusAddressParamTypeIds.value(child->thingClassId())).toUInt(); bool modelFoundForChild = false; - foreach (SunSpecModel *model, connection->models()) { + foreach (SunSpecModel *model, knownModels) { if (childModelId == model->modelId() && childModbusAddress == model->modbusStartRegister()) { modelFoundForChild = true; break; @@ -856,12 +879,51 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne } if (!modelFoundForChild) { - qCInfo(dcSunSpec()) << "The model for" << child << "does not seem to be available any more on" << connection << "Removing the device since it does not seem to exist ony more on this connection."; + qCInfo(dcSunSpec()) << "The model for" << child << "does not seem to be available any more on" << thing->name() << "Removing the device since it does not seem to exist ony more on this connection."; emit autoThingDisappeared(child->id()); } } } +bool IntegrationPluginSunSpec::orphanSweepAllowed(Thing *thing) +{ + bool everyConnectionReported = true; + foreach (SunSpecConnection *connection, connectionsFor(thing->id())) { + if (!m_discoveredConnections.contains(connection)) { + everyConnectionReported = false; + break; + } + } + + if (everyConnectionReported) { + m_orphanSweepDeadlines.remove(thing->id()); + return true; + } + + /* Hold the sweep back, but not forever: a connection whose discovery never + * returns would otherwise freeze it for good, and a child thing left over + * from a previous setup would never be cleaned up. + * + * The grace period is deliberately generous. The two outcomes are not + * comparable: sweeping too late leaves a stale thing showing as + * disconnected, sweeping too early deletes a thing of the user. */ + if (!m_orphanSweepDeadlines.contains(thing->id())) { + m_orphanSweepDeadlines.insert(thing->id(), QDateTime::currentDateTime().addSecs(OrphanSweepGraceSeconds)); + qCDebug(dcSunSpec()) << "Holding the orphan sweep of" << thing->name() + << "back until every connection has reported, or for" + << OrphanSweepGraceSeconds << "s at the most."; + return false; + } + + if (QDateTime::currentDateTime() < m_orphanSweepDeadlines.value(thing->id())) + return false; + + qCWarning(dcSunSpec()) << "A connection of" << thing->name() << "never finished a discovery within" + << OrphanSweepGraceSeconds << "s. Sweeping orphan children against the connections" + << "that did report."; + return true; +} + /* Creates one SunSpec connection for a thing. * @@ -1068,6 +1130,8 @@ void IntegrationPluginSunSpec::destroyConnections(const ThingId &thingId) if (m_meterConnections.contains(thingId)) m_meterConnections.take(thingId)->deleteLater(); + + m_orphanSweepDeadlines.remove(thingId); } SunSpecConnection *IntegrationPluginSunSpec::connectionForChild(Thing *child) const diff --git a/sunspec/integrationpluginsunspec.h b/sunspec/integrationpluginsunspec.h index 9c60331..8ba7aea 100644 --- a/sunspec/integrationpluginsunspec.h +++ b/sunspec/integrationpluginsunspec.h @@ -118,6 +118,15 @@ private: bool sunspecThingAlreadyAdded(uint modelId, uint modbusAddress, const ThingId &parentId); void processDiscoveryResult(Thing *thing, SunSpecConnection *connection); + /* Whether the children of a parent may be confronted with the models found + * so far. False while a connection of that parent has not reported yet, so + * that one connection does not sweep away the children of the other. + * + * INFERRED: no measurement backs this grace period. See D-2 in + * DIVERGENCES.md for how to replace it with one. */ + bool orphanSweepAllowed(Thing *thing); + QHash m_orphanSweepDeadlines; + // SunSpec things SunSpecConnection *createConnection(Thing *thing, uint slaveId, bool isPrimary); From 9e0cd44af0c5bee378bd301f7128fba310ae741c Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:07:06 +0200 Subject: [PATCH 19/24] sunspec: add a device suite for the two connection behaviour The two most certain defects of the multi unit ID work are invisible to the offline suites, because they only appear once a discovery has actually run: the orphan sweep deleting the children of the other connection, and the aggregation of the connected state. This suite mounts the plugin behind a real ThingManager against a real device. It is deliberately not a testcase, so "make check" never runs it, but it is still built so it cannot rot unnoticed. Without SUNSPEC_TEST_HOST every test skips, and no address is written into the repository: the address of a real installation is customer data. NOT YET RUN against hardware. It needs the address of a GEN24 and, given the polling rate guidance, a deliberate decision to point it at a customer inverter. Co-Authored-By: Claude Opus 5 (1M context) --- sunspec/tests/integration/integration.pro | 75 +++ .../tests/integration/tst_sunspecdevice.cpp | 456 ++++++++++++++++++ sunspec/tests/tests.pro | 7 +- 3 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 sunspec/tests/integration/integration.pro create mode 100644 sunspec/tests/integration/tst_sunspecdevice.cpp diff --git a/sunspec/tests/integration/integration.pro b/sunspec/tests/integration/integration.pro new file mode 100644 index 0000000..e33bbfa --- /dev/null +++ b/sunspec/tests/integration/integration.pro @@ -0,0 +1,75 @@ +include(../../../sunspec.pri) + +QT += testlib network serialbus +QT -= gui +TEMPLATE = app +TARGET = tst_sunspecdevice + +# Suite d'integration F3 : le plugin, monte derriere un vrai ThingManager, face +# a un vrai appareil. +# +# VOLONTAIREMENT PAS "testcase" : "make check" ne la lance jamais. Elle est +# quand meme construite, pour qu'elle ne pourrisse pas en silence. +# +# Elle exige un appareil joignable, passe par variable d'environnement : +# +# SUNSPEC_TEST_HOST= [SUNSPEC_TEST_PORT=502] +# [SUNSPEC_TEST_METER_SLAVE_ID=200] [SUNSPEC_TEST_SLAVE_ID=1] +# ./tst_sunspecdevice +# +# Sans SUNSPEC_TEST_HOST, chaque test se met en QSKIP. Aucune adresse par +# defaut n'est ecrite ici : l'adresse d'une installation reelle est une donnee +# client et n'a rien a faire dans un depot a miroir public (voir AGENTS.md). +# +# ATTENTION avant de la lancer sur un onduleur client : le constructeur +# recommande de rester au-dessus de 7 s d'intervalle d'interrogation, faute de +# quoi l'onduleur peut passer hors ligne vis-a-vis de son portail, avec +# retablissement par coupure AC+DC sur site. Cette suite ne fait qu'un cycle de +# decouverte et n'interroge pas en boucle, mais elle ouvre bien deux connexions. + +CONFIG += console c++17 +CONFIG -= app_bundle + +CONFIG += link_pkgconfig +PKGCONFIG += nymea nymea-core + +PLUGIN_DIR = $$PWD/../.. +INCLUDEPATH += $$PLUGIN_DIR + +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_sunspecdevice.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/integration/tst_sunspecdevice.cpp b/sunspec/tests/integration/tst_sunspecdevice.cpp new file mode 100644 index 0000000..4566b91 --- /dev/null +++ b/sunspec/tests/integration/tst_sunspecdevice.cpp @@ -0,0 +1,456 @@ +// 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(); + +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; + /* 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; +}; + +static const ThingId DeviceThingId = ThingId("5d3f8a26-41bc-4e79-a05f-9b7c2e6d1483"); + +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"); + + NymeaSettings settings(NymeaSettings::SettingsRoleThings); + settings.beginGroup("ThingConfig"); + settings.beginGroup(DeviceThingId.toString()); + settings.setValue("thingName", "Fronius"); + settings.setValue("pluginid", metadata.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(), m_meterSlaveId); + settings.endGroup(); + settings.endGroup(); + settings.endGroup(); + settings.sync(); + + 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. */ + bool primaryDiscovered = false; + connect(primary, &SunSpecConnection::discoveryFinished, this, [&primaryDiscovered](bool success) { + if (success) + primaryDiscovered = true; + }); + connect(meter, &SunSpecConnection::connectedChanged, this, [this, &primaryDiscovered](bool connected) { + if (connected && !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); +} + +QTEST_MAIN(SunSpecDeviceTest) +#include "tst_sunspecdevice.moc" diff --git a/sunspec/tests/tests.pro b/sunspec/tests/tests.pro index 7c745ad..9eadc09 100644 --- a/sunspec/tests/tests.pro +++ b/sunspec/tests/tests.pro @@ -9,7 +9,12 @@ TEMPLATE = subdirs # portant deux SunSpecConnection (F3). Binaire distinct pour ne pas # fausser les compteurs d'appels globaux verifies par plugin. +# integration : le plugin face a un vrai appareil. Construite mais JAMAIS lancee +# par "make check" : elle exige un onduleur joignable, passe par +# SUNSPEC_TEST_HOST. Voir integration/integration.pro. + SUBDIRS += \ logic \ plugin \ - multiunit + multiunit \ + integration From 6eb25045e282ecd7c385b9c9ba864df09e4657e7 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:10:48 +0200 Subject: [PATCH 20/24] sunspec: pin the single connection vendors against regressions The whole point of keeping the meter connection in a hash of its own was that not a line of the generic and solarEdge vendors would change. Checks it rather than claiming it: a stored solarEdge connection, whose behaviour no suite covered until now, must still end up with exactly one connection, as must a generic one. Also records the connection a discovery came from in processDiscoveryResult() rather than in the discoveryFinished handler. The initial setup calls it from a second handler on the same signal, so recording it there made the orphan sweep depend on the order Qt happens to invoke the two. Co-Authored-By: Claude Opus 5 (1M context) --- specs/fronius-sunspec.md | 11 ++++++--- sunspec/integrationpluginsunspec.cpp | 7 +++++- .../tests/multiunit/tst_sunspecmultiunit.cpp | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/specs/fronius-sunspec.md b/specs/fronius-sunspec.md index 63612c9..584ecad 100644 --- a/specs/fronius-sunspec.md +++ b/specs/fronius-sunspec.md @@ -317,10 +317,10 @@ confirmation humaine au moment de l'implémentation. | # | Point | Statut au contrat | Hypothèse retenue | |---|---|---|---| -| H-1 | Architecture multi-unit-ID (D1) | ⬜ en attente de validation | **Option A′** : un thing `froniusConnection` portant deux `SunSpecConnection` internes (onduleur `slaveId`, compteur `meterSlaveId`). C'est l'option recommandée par le contrat : UX correcte, aucune modification de `libnymea-sunspec`, rebasabilité préservée. | +| H-1 | Architecture multi-unit-ID (D1) | ✅ tranché, **implémenté** | **Option A′** : un thing `froniusConnection` portant deux `SunSpecConnection` internes (onduleur `slaveId`, compteur `meterSlaveId`). UX correcte, aucune modification de `libnymea-sunspec`, rebasabilité préservée. La seconde connexion vit dans une table distincte de `m_sunSpecConnections`, ce qui laisse les vendors générique et `solarEdge` inchangés ligne pour ligne. | | H-2 | Watchdog batterie (D4) | ⬜ à valider | **`RvrtTms = 300 s`** à chaque consigne (AC-10). Prérequis non levé : sémantique du timer à vérifier sur le modèle 124, pas seulement sur le 123. | | H-3 | Ordre des octets du GEN24 | inférence explicite du brief | Défaut SunSpec, little-endian (AC-6). Non documenté par le constructeur. | -| H-4 | Deux connexions TCP simultanées vers le même onduleur | inférence explicite du brief | Non supposé résolu. Le constructeur recommande des requêtes séquentielles ; deux boucles d'interrogation parallèles violeraient potentiellement cette recommandation. **À mesurer au banc**, pas à supposer. En attendant, sérialiser. | +| H-4 | Deux connexions TCP simultanées vers le même onduleur | ✅ **mesuré** sur le GEN24 client | Les deux connexions tiennent simultanément. Reste distincte, et **non mesurée**, la charge de deux *découvertes* simultanées : le plugin ne monte donc la connexion compteur qu'une fois la découverte primaire terminée. La sérialisation des lectures d'interrogation reste un chantier à part. | | H-5 | Assistant de tarification écrasant les consignes | « à confirmer », absent de la doc constructeur | Constat empirique tiers. Traité en message de diagnostic (AC-13.2), pas en logique de pilotage. | | H-6 | Ordre des phases du chantier | contrat §8 : la phase 2 ne démarre pas avant que la phase 1 (simulateur) soit verte | **Précondition non vérifiée ici.** Le simulateur `hems-sim` n'a pas été exécuté. Les AC ci-dessous sont validés par tests unitaires hors ligne, ce qui ne remplace pas une validation contre un simulateur reproduisant la sémantique du constructeur. | @@ -359,7 +359,12 @@ confirmation humaine au moment de l'implémentation. ## Tableau de statut -Suite : `sunspec/tests/`, **38 tests, 38 verts**. +Suite : `sunspec/tests/`, **60 tests, 60 verts** — 44 en logique pure, 6 sur +l'aiguillage du plugin, 10 sur le multi unit ID. + +Une quatrième suite, `tests/integration`, monte le plugin face à un vrai +appareil. Elle est construite mais **jamais lancée par `make check`** : elle +exige `SUNSPEC_TEST_HOST`. **Elle n'a pas encore été exécutée sur matériel.** Build : `qmake6 NYMEA_SUNSPEC_PATH= sunspec/tests/tests.pro && make check`. Sans `NYMEA_SUNSPEC_PATH`, `sunspec.pri` résout `libnymea-sunspec` par diff --git a/sunspec/integrationpluginsunspec.cpp b/sunspec/integrationpluginsunspec.cpp index e033fc6..dc27726 100644 --- a/sunspec/integrationpluginsunspec.cpp +++ b/sunspec/integrationpluginsunspec.cpp @@ -761,6 +761,12 @@ void IntegrationPluginSunSpec::processDiscoveryResult(Thing *thing, SunSpecConne { qCInfo(dcSunSpec()) << "Processing discovery result from" << thing->name() << connection; + /* Recorded here rather than in the discoveryFinished handler: the initial + * setup calls this function from a second handler on the same signal, so + * doing it there would make the orphan sweep depend on the order Qt happens + * to invoke the two. */ + m_discoveredConnections.insert(connection); + foreach (SunSpecModel *model, connection->models()) { Thing *modelThing = getThingForSunSpecModel(model->modelId(), model->modbusStartRegister(), thing->id()); if (modelThing) { @@ -1023,7 +1029,6 @@ SunSpecConnection *IntegrationPluginSunSpec::createConnection(Thing *thing, uint connect(connection, &SunSpecConnection::discoveryFinished, thing, [this, connection, thing, isPrimary] (bool success) { if (success) { qCDebug(dcSunSpec()) << "Discovery finished successfully of" << connection; - m_discoveredConnections.insert(connection); processDiscoveryResult(thing, connection); } else { qCWarning(dcSunSpec()) << "Discovery finished with errors on" << connection; diff --git a/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp b/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp index 0508d67..4e1a920 100644 --- a/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp +++ b/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp @@ -199,6 +199,7 @@ private slots: void test_F3_meter_slave_id_zero_creates_only_one_connection(); void test_F3_meter_slave_id_colliding_with_the_inverter_is_refused(); void test_F3_generic_connection_creates_no_meter_connection(); + void test_F3_solaredge_connection_creates_no_meter_connection(); void test_F3_removal_destroys_both_connections(); private: @@ -220,6 +221,7 @@ static const ThingId FroniusWithMeterId = ThingId("1f0a5c7e-3b42-4d18-9e6a-70 static const ThingId FroniusWithoutMeterId = ThingId("2c7d9e14-8a56-4f03-b21d-63e8a95c4072"); static const ThingId FroniusCollidingId = ThingId("3b8e2f60-95c1-42a7-8d34-51fa7e60b9c8"); static const ThingId GenericConnectionId = ThingId("4a1c6d83-27e9-4b50-a6f2-8c05d31e7ba4"); +static const ThingId SolarEdgeConnectionId = ThingId("5e2b7f49-6d38-41ca-b95e-0a4c8d71e263"); static const int MeterSlaveId = 200; static const int InverterSlaveId = 1; @@ -246,6 +248,10 @@ void SunSpecMultiUnitTest::storeConnection(const ThingId &thingId, const ThingCl settings.setValue(froniusConnectionThingPortParamTypeId.toString(), 502); settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId); settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId); + } else if (thingClassId == solarEdgeConnectionThingClassId) { + settings.setValue(solarEdgeConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); + settings.setValue(solarEdgeConnectionThingPortParamTypeId.toString(), 502); + settings.setValue(solarEdgeConnectionThingSlaveIdParamTypeId.toString(), InverterSlaveId); } else { settings.setValue(sunspecConnectionThingAddressParamTypeId.toString(), "192.0.2.1"); settings.setValue(sunspecConnectionThingPortParamTypeId.toString(), 502); @@ -299,6 +305,8 @@ void SunSpecMultiUnitTest::initTestCase() metadata.pluginId(), InverterSlaveId); storeConnection(GenericConnectionId, sunspecConnectionThingClassId, "SunSpec generique", metadata.pluginId(), 0); + storeConnection(SolarEdgeConnectionId, solarEdgeConnectionThingClassId, "SolarEdge", + metadata.pluginId(), 0); m_hardwareManager = new FakeHardwareManager(this); m_thingManager = new ThingManagerImplementation(m_hardwareManager, nullptr, QLocale(), this); @@ -397,6 +405,21 @@ void SunSpecMultiUnitTest::test_F3_generic_connection_creates_no_meter_connectio "le vendor generique garde exactement une connexion par thing"); } +/* Non regression du vendor solarEdge, celui dont aucune suite ne couvrait le + * comportement. C'est la raison pour laquelle la connexion compteur vit dans + * une table separee plutot que dans un QHash de listes : ainsi pas une seule + * ligne de solarEdge n'a change, y compris la recherche inverse + * m_sunSpecConnections.key() dont depend la decouverte des batteries. */ +void SunSpecMultiUnitTest::test_F3_solaredge_connection_creates_no_meter_connection() +{ + Thing *thing = awaitThing(SolarEdgeConnectionId); + QVERIFY(thing); + + QVERIFY(m_plugin->testPrimaryConnection(SolarEdgeConnectionId)); + QVERIFY2(!m_plugin->testMeterConnection(SolarEdgeConnectionId), + "le vendor solarEdge garde exactement une connexion par thing"); +} + /* thingRemoved() est une chaine de else if dont la premiere branche portait sur * la seule connexion primaire. Sans reprise explicite, la connexion compteur * survit a son thing : socket ouverte et signaux pointant vers un Thing detruit. From 1e02400acd5ea67b26dbfeb947db3ec198166075 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:11:34 +0200 Subject: [PATCH 21/24] sunspec: keep Qt Creator session files out of the repository They carry absolute local paths and differ per workstation. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6a27cf1..2eea77d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ build-*/ sunspec/CONTRAT-fronius-sunspec.md sunspec/brief-agent-plugin.md sunspec/CLAUDE-nymea-plugin-sunspec.md + +# Fichier de session Qt Creator : chemins absolus locaux, propre a chaque poste. +*.pro.user From 955016cc471ff1742b09236af77ca672d0f7509c Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 09:51:10 +0200 Subject: [PATCH 22/24] sunspec: fix a dangling capture in the device suite and widen it The suite captured a stack local of initTestCase() by reference in two lambdas that outlive it. Once the function returned they wrote into a stack slot reused by later frames, corrupting the running test: a QSignalSpy reported a phantom emission, reading its recorded arguments segfaulted, and the count changed when unrelated locals were added. Both observed failures came from that, not from the plugin. The two flags are members now. Covers the two remaining claims the offline suites cannot reach: a meter slave ID of 0 opens no second connection against a real device, and losing the meter connection leaves the parent connected and the inverter data flowing, marking only the meter child disconnected. That last test drives one explicit block read first. This bench replaces the plugin timer with a null object, on purpose, so nothing would otherwise ever mark a child connected and the cut would prove nothing. Run against the bench simulator: 8 tests, 8 green. Each of the two corrections was confirmed able to fail by reintroducing its pre-F3 form. Co-Authored-By: Claude Opus 5 (1M context) --- specs/fronius-sunspec.md | 14 +- .../tests/integration/tst_sunspecdevice.cpp | 168 +++++++++++++++--- 2 files changed, 161 insertions(+), 21 deletions(-) diff --git a/specs/fronius-sunspec.md b/specs/fronius-sunspec.md index 584ecad..6517faa 100644 --- a/specs/fronius-sunspec.md +++ b/specs/fronius-sunspec.md @@ -364,7 +364,19 @@ l'aiguillage du plugin, 10 sur le multi unit ID. Une quatrième suite, `tests/integration`, monte le plugin face à un vrai appareil. Elle est construite mais **jamais lancée par `make check`** : elle -exige `SUNSPEC_TEST_HOST`. **Elle n'a pas encore été exécutée sur matériel.** +exige `SUNSPEC_TEST_HOST`. + +**Exécutée le 2026-08-02 contre le simulateur `froniusgen24-sunspec` du banc : +8 tests, 8 verts.** Unit 1 = modèles 1/113/120-123/160, unit 200 = 1/213, sans +batterie. Les deux corrections qu'aucune suite hors ligne ne pouvait couvrir y +sont vérifiées, et chacune a été confirmée capable d'échouer en réintroduisant +la forme pré-F3 : le balayage des orphelins non corrigé supprime **les deux** +enfants en deux cycles de découverte, et la propagation de `connected` non +scindée fait passer le parent déconnecté sur la seule chute du compteur. + +Reste non exécuté sur **matériel réel** : le GEN24 client est une installation +distante, écartée pour ce lot (une découverte ratée y demanderait une coupure +AC+DC sur site). Build : `qmake6 NYMEA_SUNSPEC_PATH= sunspec/tests/tests.pro && make check`. Sans `NYMEA_SUNSPEC_PATH`, `sunspec.pri` résout `libnymea-sunspec` par diff --git a/sunspec/tests/integration/tst_sunspecdevice.cpp b/sunspec/tests/integration/tst_sunspecdevice.cpp index 4566b91..87cf26a 100644 --- a/sunspec/tests/integration/tst_sunspecdevice.cpp +++ b/sunspec/tests/integration/tst_sunspecdevice.cpp @@ -241,6 +241,8 @@ private slots: 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; @@ -255,15 +257,47 @@ private: 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() { @@ -322,21 +356,11 @@ void SunSpecDeviceTest::initTestCase() const PluginMetadata metadata(document.object()); QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide"); - NymeaSettings settings(NymeaSettings::SettingsRoleThings); - settings.beginGroup("ThingConfig"); - settings.beginGroup(DeviceThingId.toString()); - settings.setValue("thingName", "Fronius"); - settings.setValue("pluginid", metadata.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(), m_meterSlaveId); - settings.endGroup(); - settings.endGroup(); - settings.endGroup(); - settings.sync(); + 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); @@ -357,13 +381,12 @@ void SunSpecDeviceTest::initTestCase() /* Arme avant toute attente, pour que l'ordre des deux evenements soit * observe et pas reconstitue apres coup. */ - bool primaryDiscovered = false; - connect(primary, &SunSpecConnection::discoveryFinished, this, [&primaryDiscovered](bool success) { + connect(primary, &SunSpecConnection::discoveryFinished, this, [this](bool success) { if (success) - primaryDiscovered = true; + m_primaryDiscovered = true; }); - connect(meter, &SunSpecConnection::connectedChanged, this, [this, &primaryDiscovered](bool connected) { - if (connected && !primaryDiscovered) + connect(meter, &SunSpecConnection::connectedChanged, this, [this](bool connected) { + if (connected && !m_primaryDiscovered) m_meterConnectedBeforePrimaryDiscovery = true; }); @@ -452,5 +475,110 @@ void SunSpecDeviceTest::test_F3_no_child_disappears_across_repeated_discoveries( 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" From f839f5d435bc1798f587db3de047c28d99820e2b Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 10:29:00 +0200 Subject: [PATCH 23/24] docs: document the branch and promotion model Committed verbatim from the working tree, where it had been sitting uncommitted. Written by Patrick, not by the agent: this commit only puts it under version control so the promotion chain it describes can be corrected against reality in the next one. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 175 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f902e52..3dff451 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,184 @@ # AGENTS.md — etm-powersync-plugins-modbus -Plugins Modbus ETM (fork GPL) : compteurs, bornes, relais. -Contient abbb2x, abbterra, eastron, waveshare-relay-d8. +Tous les plugins Modbus supportés par ETM : plugins propres et forks de plugins amont. +Compteurs, bornes de recharge, relais, onduleurs. -- **Licence** : GPL-3.0 (+ LGPL pour libnymea-modbus / libnymea-sunspec) · **Miroir public** : OUI -- **Agent** : aucun en beta — plugins déjà publiés, repo de maintenance/référence. +- **Licence** : GPL-3.0 pour les plugins, LGPL-3.0 pour `libnymea-modbus` / `libnymea-sunspec` +- **Miroir public** : OUI +- **Paquets** : publiés sur `repos.etm-powersync.fr`, trois canaux + +L'inventaire des plugins est l'arborescence du dépôt. Ne pas se fier à une liste écrite ici : elle +serait périmée, et un agent en conclurait que le reste n'existe pas. + +Ce dépôt est autoportant : tout ce qui est nécessaire pour y travailler est ici. + +## Placement du travail non amont + +Une fois qu'un développement — plugin, correctif, extension vendor — n'est ni upstream officiel ni +destiné à y retourner à court terme, il vit dans le dépôt produit ETM correspondant +(`etm-powersync-plugins-modbus` ou `etm-powersync-plugins`), vendoré depuis sa source si elle existe +ailleurs (`VENDORED.md` avec commit et date), développé directement sinon. Le fork correspondant +(`nymea-plugins-modbus` ou `nymea-plugins`) ne garde que ce qui reste candidat à une contribution +amont. + +## Branches et canaux + +``` +feature/* ──► landing-silo ──► experimental-silo ──► master + (nightly) (testing) (stable) +``` + +Vers la droite on promeut, vers la gauche on redescend un correctif. Une correction ne saute +jamais un cran. + +`experimental-silo` n'est pas une branche de travail : elle ne reçoit que ce qui a déjà survécu au +nightly. Le travail se fait dans `feature/*`. + +### Critères de promotion + +| Passage | Critère | +|---|---| +| commits dans `feature/*` | `verify` en local, l'agent boucle seul | +| `feature/*` → `landing-silo` | PR, `verify` vert sur le runner, AC de la spec couverts par des tests | +| `landing-silo` → `experimental-silo` | build complet propre, banc nocturne vert | +| `experimental-silo` → `master` | validation matérielle nommée : appareil, firmware, configuration | + +Le dernier critère n'est pas automatisable et ne doit pas l'être. + +### Correctifs + +**Avant de créer une branche : reproduire le bug et déterminer la branche la plus proche de +`master` où il apparaît.** C'est elle qui décide du point de départ, pas l'urgence ressentie. + +| Le bug se reproduit… | Départ | Ensuite | +|---|---|---| +| jusque dans `master` | `hotfix/*` depuis `master` | fusion dans `master`, puis redescente dans `experimental-silo`, puis dans `landing-silo` | +| dans `experimental-silo`, pas dans `master` | `fix/*` depuis `experimental-silo` | redescente dans `landing-silo` | +| seulement dans `landing-silo` | `fix/*` depuis `landing-silo` | rien, il remonte par la promotion normale | + +Ne jamais corriger en aval un bug présent en amont : le correctif remontera un jour, mais la +branche amont reste cassée entre-temps et la promotion suivante réintroduit l'ancien code par +dessus. Symptôme : « le correctif ne tient pas ». + +Si une fusion casse le nightly, **révoquer la fusion** dans `landing-silo` pour rendre le canal de +nouveau utilisable, puis corriger tranquillement dans la branche `feature/*` d'origine. Ne pas +corriger en avant sous pression. + +Trois préfixes, pas davantage : `feature/*`, `fix/*`, `hotfix/*`. + +### Versionnement des paquets + +Tout paquet issu d'un dépôt produit ETM porte un suffixe `+etmN` sur la version amont qu'il modifie +ou remplace (ex. `1.15.2+etm1`), à tous les canaux de promotion (`landing-silo`/nightly, +`experimental-silo`/testing, `main`/stable). Ce suffixe garantit la préséance apt/dpkg sur le paquet +upstream identique. + +Ne jamais le remplacer par un tiret (`-etmN`) : `-` a un sens réservé en version Debian (champ +`debian_revision`) et entre en conflit avec une éventuelle révision Debian amont. + +## Agents + +Les agents travaillent dans `feature/*` et `fix/*`. Jamais de commit direct dans `landing-silo`, +`experimental-silo` ou `master` : ces branches ne reçoivent que des fusions. + +Une spec, une branche, un jeu de critères d'acceptation. + +Un agent qui estime qu'un refactor s'impose sur un plugin publié le signale et s'arrête. Il ne le +fait pas. ## Invariants locaux -1. NE PAS renommer les paquets PUBLIÉS : `powersync-plugin-eastron`, + +1. **NE PAS renommer les paquets PUBLIÉS** : `powersync-plugin-eastron`, `powersync-plugin-abbb2x`, `nymea-plugin-abbterra`. Casse l'upgrade des box beta. + Valable sur toutes les branches : un renommage dans `landing-silo` finit par atteindre `master`. 2. `abbterra` est volontairement sous nom amont (transition upstream propre) — ne pas « corriger ». 3. Matrice validée beta : SDM72 / SDM120 (eastron), Waveshare D8. -4. Aucun composant propriétaire ici. +4. Aucun composant propriétaire ici. Aucun ne doit y entrer. -Carte globale et frontières : voir `../AGENTS.md`. +## Miroir public — ne doit jamais être commité + +Données client : adresses de site, noms, numéros de série, relevés, adresses IP d'installations +réelles. Identifiants et jetons. Documentation constructeur sous réserve de confidentialité — elle +se cite en référence (titre, version, section) dans la table de provenance des registres, jamais +en contenu. + +Les captures, journaux et dumps de registres joints à un correctif sont expurgés avant commit. + +--- + +## Repository Guidelines + +These conventions apply to every `*-plugins-modbus` repository. They are not a style preference: +plugins from all of them load into the **same nymea-core**, so they share its ABI, its Qt version, +its plugin metadata format and its interface definitions. A repository that diverges produces +plugins that do not interoperate. + +**Target: nymea-core ``.** Where these guidelines and the core repository's `AGENTS.md` +disagree, the core repository wins and this section is corrected. + +This section is identical, byte for byte, across every `*-plugins-modbus` repository, and is the +part that can be contributed upstream. Anything specific to one repository belongs above it. + +### Plugin anatomy + +One directory per vendor, holding its sources, headers, plugin metadata (`*.json`), translations +and packaging fragments. Plugin IDs, translation filenames and resource prefixes follow the +directory name. + +Shared code stays in the shared libraries. Never move code between an LGPL library and a GPL +plugin without addressing the licence change explicitly. + +### Build + +Requires `plugin.pri`, provided by `libnymea1-dev`, or passed explicitly via `PLUGIN_PRI=`. + +``` +qmake .pro && make -j"$(nproc)" # whole repository +cd && qmake .pro && make # single plugin — the short loop, preferred +``` + +Select the Qt version before calling qmake. When building against a nymea source tree rather than +a system-wide install, `PKG_CONFIG_PATH`, `CPATH`, `LIBRARY_PATH`, `LD_LIBRARY_PATH` and a `PATH` +extended with the `nymea-plugininfocompiler` build directory are also required. + +Verbose logs: `QT_LOGGING_RULES="*.debug=true"`. + +### Style + +Qt conventions: 4-space indent, `CamelCase` classes, `lowerCamelCase` functions and members with +the `m_` prefix, Qt containers and signals over STL, log output wrapped in +`NYMEA_LOGGING_CATEGORY`, and `foreach` rather than range-based `for` to keep C++11 compatibility. + +Refresh translations whenever user-visible text changes. + +### Register provenance + +**Never infer a register address.** Every address comes from the manufacturer's documentation or +from a read performed against a real device. A value taken from a third-party implementation is a +hypothesis until verified on hardware, and is documented as such. + +Addresses, scale factors and endianness belong in the vendor's register description, not scattered +as literals through the implementation. + +### Hardware validation + +Compiling is not verifying. Any claim of validation names the device, its firmware version and its +configuration. Without hardware, say so and describe the validation still required rather than +implying it was performed. + +Where a Modbus server can be simulated, prefer a captured register dump replayed against the +plugin over a hand-written mock: a dump carries the quirks a mock will not. + +### Device writes + +A write can damage equipment or void a warranty. Write paths are reviewed more strictly than read +paths: state what is written, under which conditions, and what happens if the plugin stops +mid-operation. + +### Commits and pull requests + +Imperative subjects under ~72 characters. +Pull request titles: `Plugin name: Add support for xyz` or `New plugin: Plugin name`. +State whether the change was tested on hardware, and if not, name someone who can confirm it. +Update the plugin's `README.md` and the translations. +Call out packaging impacts — Qt 5 and Qt 6 packaging are maintained separately. From 0cdcfe433b70bfdd2e174dde138230d478705486 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Sun, 2 Aug 2026 10:29:32 +0200 Subject: [PATCH 24/24] docs: align the branch model with the actual remote Two points the document got wrong. The stable branch is main, not master: the remote has never had a master. And the promotion chain was written as if it existed, while landing-silo and experimental-silo were created only today, the first as empty branches off main. Nothing else in the document is touched. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3dff451..a43d3f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,13 +24,18 @@ amont. ## Branches et canaux ``` -feature/* ──► landing-silo ──► experimental-silo ──► master +feature/* ──► landing-silo ──► experimental-silo ──► main (nightly) (testing) (stable) ``` Vers la droite on promeut, vers la gauche on redescend un correctif. Une correction ne saute jamais un cran. +Les trois branches existent sur le remote depuis le 2026-08-02. Les deux silos ont été créés +vides depuis `main` ; `landing-silo` a ensuite reçu `feature/fronius-sunspec` comme premier +contenu, `experimental-silo` est encore au niveau de `main`. Avant cette date le modèle +ci-dessus était une intention, pas un état du dépôt. + `experimental-silo` n'est pas une branche de travail : elle ne reçoit que ce qui a déjà survécu au nightly. Le travail se fait dans `feature/*`. @@ -41,19 +46,19 @@ nightly. Le travail se fait dans `feature/*`. | commits dans `feature/*` | `verify` en local, l'agent boucle seul | | `feature/*` → `landing-silo` | PR, `verify` vert sur le runner, AC de la spec couverts par des tests | | `landing-silo` → `experimental-silo` | build complet propre, banc nocturne vert | -| `experimental-silo` → `master` | validation matérielle nommée : appareil, firmware, configuration | +| `experimental-silo` → `main` | validation matérielle nommée : appareil, firmware, configuration | Le dernier critère n'est pas automatisable et ne doit pas l'être. ### Correctifs **Avant de créer une branche : reproduire le bug et déterminer la branche la plus proche de -`master` où il apparaît.** C'est elle qui décide du point de départ, pas l'urgence ressentie. +`main` où il apparaît.** C'est elle qui décide du point de départ, pas l'urgence ressentie. | Le bug se reproduit… | Départ | Ensuite | |---|---|---| -| jusque dans `master` | `hotfix/*` depuis `master` | fusion dans `master`, puis redescente dans `experimental-silo`, puis dans `landing-silo` | -| dans `experimental-silo`, pas dans `master` | `fix/*` depuis `experimental-silo` | redescente dans `landing-silo` | +| jusque dans `main` | `hotfix/*` depuis `main` | fusion dans `main`, puis redescente dans `experimental-silo`, puis dans `landing-silo` | +| dans `experimental-silo`, pas dans `main` | `fix/*` depuis `experimental-silo` | redescente dans `landing-silo` | | seulement dans `landing-silo` | `fix/*` depuis `landing-silo` | rien, il remonte par la promotion normale | Ne jamais corriger en aval un bug présent en amont : le correctif remontera un jour, mais la @@ -79,7 +84,7 @@ Ne jamais le remplacer par un tiret (`-etmN`) : `-` a un sens réservé en versi ## Agents Les agents travaillent dans `feature/*` et `fix/*`. Jamais de commit direct dans `landing-silo`, -`experimental-silo` ou `master` : ces branches ne reçoivent que des fusions. +`experimental-silo` ou `main` : ces branches ne reçoivent que des fusions. Une spec, une branche, un jeu de critères d'acceptation. @@ -90,7 +95,7 @@ fait pas. 1. **NE PAS renommer les paquets PUBLIÉS** : `powersync-plugin-eastron`, `powersync-plugin-abbb2x`, `nymea-plugin-abbterra`. Casse l'upgrade des box beta. - Valable sur toutes les branches : un renommage dans `landing-silo` finit par atteindre `master`. + Valable sur toutes les branches : un renommage dans `landing-silo` finit par atteindre `main`. 2. `abbterra` est volontairement sous nom amont (transition upstream propre) — ne pas « corriger ». 3. Matrice validée beta : SDM72 / SDM120 (eastron), Waveshare D8. 4. Aucun composant propriétaire ici. Aucun ne doit y entrer.