diff --git a/.gitignore b/.gitignore index cf32e71..2eea77d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,19 @@ 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 + +# Fichier de session Qt Creator : chemins absolus locaux, propre a chaque poste. +*.pro.user diff --git a/AGENTS.md b/AGENTS.md index f902e52..a43d3f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,189 @@ # 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 ──► 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/*`. + +### 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` → `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 +`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 `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 +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 `main` : 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 `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. +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. diff --git a/abbterra/abbterra-registers.json b/abbterra/abbterra-registers.json index 18c2325..a2b385c 100644 --- a/abbterra/abbterra-registers.json +++ b/abbterra/abbterra-registers.json @@ -1,10 +1,10 @@ { "className": "AbbTerra", "protocol": "BOTH", + "checkReachableRegister": "serialNumber", "endianness": "BigEndian", "stringEndianness": "BigEndian", "errorLimitUntilNotReachable": 3, - "checkReachableRegister": "serialNumber", "queuedRequests": true, "queuedRequestsDelay": 50, "blocks": [ diff --git a/abbterra/abbterratcpdiscovery.cpp b/abbterra/abbterratcpdiscovery.cpp index e0077f1..a48cf9d 100644 --- a/abbterra/abbterratcpdiscovery.cpp +++ b/abbterra/abbterratcpdiscovery.cpp @@ -92,9 +92,32 @@ void AbbTerraTcpDiscovery::checkNetworkDevice(const QHostAddress &address) void AbbTerraTcpDiscovery::cleanupConnection(AbbTerraModbusTcpConnection *connection) { - m_connections.removeAll(connection); - connection->disconnectDevice(); - connection->deleteLater(); + // 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) so the teardown below cannot re-enter this method. + disconnect(connection, nullptr, this, nullptr); + disconnect(connection->modbusTcpMaster(), nullptr, this, nullptr); + + // 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 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/specs/fronius-sunspec.md b/specs/fronius-sunspec.md new file mode 100644 index 0000000..6517faa --- /dev/null +++ b/specs/fronius-sunspec.md @@ -0,0 +1,437 @@ +# 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) | ✅ 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 | ✅ **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. | + +--- + +## 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 + +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`. + +**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 +`pkg-config`, ce qui suppose `libnymea-sunspec-dev` installé système. + +| AC | Objet | Test | Statut | +|---|---|---|---| +| 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. 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/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`. 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/froniusrules.cpp b/sunspec/froniusrules.cpp new file mode 100644 index 0000000..bd0bb50 --- /dev/null +++ b/sunspec/froniusrules.cpp @@ -0,0 +1,189 @@ +// 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; +} + +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); +} + +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..f98fd4d --- /dev/null +++ b/sunspec/froniusrules.h @@ -0,0 +1,142 @@ +// 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; +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); + +/* 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 new file mode 100644 index 0000000..dc27726 --- /dev/null +++ b/sunspec/integrationpluginsunspec.cpp @@ -0,0 +1,2178 @@ +// 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 + +/* 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() +{ + +} + +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); + 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; + else if (info->thingClassId() == froniusConnectionThingClassId) + byteOrder = FroniusRules::byteOrder(); + + 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() == 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 + // 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 || + thing->thingClassId() == froniusConnectionThingClassId) { + + // Handle reconfigure + if (m_sunSpecConnections.contains(thing->id()) || m_meterConnections.contains(thing->id())) { + qCDebug(dcSunSpec()) << "Reconfiguring existing thing" << thing->name(); + destroyConnections(thing->id()); + + 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)); + } + }); + + uint slaveId = thing->paramValue(m_connectionSlaveIdParamTypeIds.value(thing->thingClassId())).toUInt(); + 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()) { + + // 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(); + // 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 + || 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(); + /* 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; + } + + 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(); + 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; + } + + 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 = effectiveUpdateInterval(); + 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(); + + /* 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)) { + 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; + } + + /* 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(); + 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); + 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); + setpoints.append({SunSpecStorageLogic::RegisterStorCtlMod, double(int(controlModeFlags))}); + } else if (action.actionTypeId() == sunspecStorageChargingRateActionTypeId) { + setpoints.append({SunSpecStorageLogic::RegisterInWRte, + action.param(sunspecStorageChargingRateActionChargingRateParamTypeId).value().toDouble()}); + } else if (action.actionTypeId() == sunspecStorageDischargingRateActionTypeId) { + 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()); + } +} + +/* 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) { + 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()) { + 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(); + 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); + }); +} + +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) +{ + 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) +{ + 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; + + /* 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) { + + // 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; + + /* 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; + + 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, knownModels) { + 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" << 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. + * + * 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(); + + 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; + 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 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(); + if (endiannessParam == "Big Endian") + endianness = SunSpecDataPoint::ByteOrderBigEndian; + + connection = new SunSpecConnection(address, port, slaveId, endianness, this); + } + + 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. + * + * 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(); + } + }); + } + + connections.insert(thing->id(), connection); + + // Update all child things connected states for this connection + connect(connection, &SunSpecConnection::connectedChanged, thing, [this, connection, thing, isPrimary] (bool connected) { + if (connected) { + qCDebug(dcSunSpec()) << connection << "connected"; + } else { + qCWarning(dcSunSpec()) << connection << "disconnected"; + } + + /* 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()) { + // We rediscovery all models on every connect to keep the child things in sync + connection->startDiscovery(); + } + } + // Update connected state of the child things of this connection + foreach (Thing *child, childThingsOn(thing, connection)) { + if (connected) { + child->setStateValue("connected", true); + } else { + markThingStatesDisconnected(child); + } + } + }); + + connect(connection, &SunSpecConnection::discoveryFinished, thing, [this, connection, thing, isPrimary] (bool success) { + if (success) { + qCDebug(dcSunSpec()) << "Discovery finished successfully of" << connection; + processDiscoveryResult(thing, connection); + } 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 + * 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::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(); + + m_orphanSweepDeadlines.remove(thingId); +} + +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; +} + +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); + + 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(); + 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)); + + /* 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}); +} + +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 = effectiveUpdateInterval(); + 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, allConnections()) { + connection->setNumberOfRetries(value.toUInt()); + } + } else if (paramTypeId == sunSpecPluginTimeoutParamTypeId) { + qCDebug(dcSunSpec()) << "Updating timeout" << value.toUInt() << "[ms]"; + foreach (SunSpecConnection *connection, allConnections()) { + 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); + + 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: + 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..8ba7aea --- /dev/null +++ b/sunspec/integrationpluginsunspec.h @@ -0,0 +1,211 @@ +// 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 "sunspecstoragelogic.h" +#include "froniusrules.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; + +#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; + 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; + + /* 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); + + /* 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); + + /* 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; + + /* 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. */ + QList childThingsOn(Thing *parent, SunSpecConnection *connection) const; + + // 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); + + // Fronius + 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); + 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(); + 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..30ec4c6 --- /dev/null +++ b/sunspec/integrationpluginsunspec.json @@ -0,0 +1,1849 @@ +{ + "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" + }, + { + "id": "74d530d1-6fd3-4afa-8acd-1e91041f88b2", + "name":"revertTime", + "displayName": "Setpoint revert time", + "type": "uint", + "unit": "Seconds", + "minValue": 0, + "maxValue": 28800, + "defaultValue": 0 + } + ], + "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": -100, + "maxValue": 100, + "unit": "Percentage", + "defaultValue": 0, + "writable": true, + "displayNameAction": "Set charging rate" + }, + { + "id": "6068f030-acce-44a2-b95f-bd00dd5ca760", + "name": "dischargingRate", + "displayName": "Discharging rate", + "displayNameEvent": "Discharging rate changed", + "type": "int", + "minValue": -100, + "maxValue": 100, + "unit": "Percentage", + "defaultValue": 0, + "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", + "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", + "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": "" + } + ] + } + ] + }, + { + "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/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 0000000..7a52a45 Binary files /dev/null and b/sunspec/sunspec.png differ diff --git a/sunspec/sunspec.pro b/sunspec/sunspec.pro new file mode 100644 index 0000000..379eccd --- /dev/null +++ b/sunspec/sunspec.pro @@ -0,0 +1,18 @@ +include(../plugins.pri) +include(../sunspec.pri) + +SOURCES += \ + froniusrules.cpp \ + integrationpluginsunspec.cpp \ + solaredgebattery.cpp \ + sunspecdiscovery.cpp \ + sunspecstoragelogic.cpp \ + sunspecthing.cpp + +HEADERS += \ + froniusrules.h \ + integrationpluginsunspec.h \ + solaredgebattery.h \ + sunspecdiscovery.h \ + sunspecstoragelogic.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/sunspecstoragelogic.cpp b/sunspec/sunspecstoragelogic.cpp new file mode 100644 index 0000000..5510a9c --- /dev/null +++ b/sunspec/sunspecstoragelogic.cpp @@ -0,0 +1,126 @@ +// 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; +} + +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 new file mode 100644 index 0000000..196b311 --- /dev/null +++ b/sunspec/sunspecstoragelogic.h @@ -0,0 +1,116 @@ +// 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. + * + * 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. */ + 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; + +/* 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 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/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..87cf26a --- /dev/null +++ b/sunspec/tests/integration/tst_sunspecdevice.cpp @@ -0,0 +1,584 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +* +* Copyright (C) 2026, ETM-PowerSync +* +* This file is part of nymea. +* +* nymea is free software: you can redistribute it and/or modify it under the +* terms of the GNU General Public License as published by the Free Software +* Foundation, either version 3 of the License, or (at your option) any later +* version. +* +* nymea is distributed in the hope that it will be useful, but WITHOUT ANY +* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +* A PARTICULAR PURPOSE. See the GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License along with +* nymea. If not, see . +* +* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +/* F3 face a un vrai appareil. + * + * Les deux defauts les plus certains du multi unit ID sont invisibles pour les + * suites hors ligne, parce qu'ils n'apparaissent qu'une fois la decouverte + * reellement passee : + * + * - le balayage des orphelins, qui sur une seule des deux connexions ferait + * disparaitre les enfants de l'autre a chaque decouverte ; + * - l'agregation de l'etat connected entre les deux connexions. + * + * Cette suite n'est pas lancee par "make check" : elle exige un appareil. Voir + * integration.pro pour les variables d'environnement. Sans SUNSPEC_TEST_HOST, + * tout se met en QSKIP. + * + * Aucune adresse par defaut n'est ecrite ici. L'adresse d'une installation + * reelle est une donnee client (AGENTS.md, "Miroir public"). + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "integrationpluginsunspec.h" + +/* -------------------------------------------------------------------------- + * Doublures + * -------------------------------------------------------------------------- */ + +/* Contrairement aux suites hors ligne, le moniteur porte ici une vraie adresse + * et se declare joignable : c'est ce qui autorise le plugin a se connecter. */ +class DeviceMonitor : public NetworkDeviceMonitor +{ +public: + DeviceMonitor(const QHostAddress &address, QObject *parent = nullptr) + : NetworkDeviceMonitor(parent), m_address(address) + { + m_info = NetworkDeviceInfo(address); + m_info.setMonitorMode(NetworkDeviceInfo::MonitorModeIp); + } + + MacAddress macAddress() const override { return MacAddress(); } + QString hostName() const override { return QString(); } + QHostAddress address() const override { return m_address; } + NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; } + NetworkDeviceInfo networkDeviceInfo() const override { return m_info; } + bool reachable() const override { return true; } + QDateTime lastSeen() const override { return QDateTime::currentDateTime(); } + uint pingRetries() const override { return 0; } + void setPingRetries(uint) override {} + +private: + QHostAddress m_address; + NetworkDeviceInfo m_info; +}; + +class DeviceDiscovery : public NetworkDeviceDiscovery +{ +public: + DeviceDiscovery(const QHostAddress &address, QObject *parent = nullptr) + : NetworkDeviceDiscovery(parent), m_address(address) {} + + NetworkDeviceDiscoveryReply *discover() override { return nullptr; } + bool running() const override { return false; } + + NetworkDeviceMonitor *registerMonitor(Thing *) override { return new DeviceMonitor(m_address, this); } + void unregisterMonitor(NetworkDeviceMonitor *monitor) override { delete monitor; } + + PingReply *ping(const QHostAddress &, uint) override { return nullptr; } + PingReply *ping(const QString &, uint) override { return nullptr; } + MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; } + MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; } + bool sendArpRequest(const QHostAddress &) override { return false; } + NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); } + + bool available() const override { return true; } + bool enabled() const override { return true; } + +protected: + void setEnabled(bool) override {} + +private: + QHostAddress m_address; +}; + +class NullPluginTimer : public PluginTimer +{ +public: + explicit NullPluginTimer(int seconds, QObject *parent = nullptr) + : PluginTimer(parent), m_interval(seconds) {} + + int interval() const override { return m_interval; } + int currentTick() const override { return 0; } + bool running() const override { return m_running; } + + void reset() override {} + void start() override { m_running = true; } + void stop() override { m_running = false; } + void pause() override {} + void resume() override {} + +private: + int m_interval = 0; + bool m_running = false; +}; + +/* Le minuteur n'est jamais declenche par cette suite. Interroger en boucle un + * onduleur client est precisement ce que le plancher de cadence cherche a + * eviter ; ici on ne veut que la decouverte. */ +class NullPluginTimerManager : public PluginTimerManager +{ +public: + explicit NullPluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {} + + PluginTimer *registerTimer(int seconds) override { return new NullPluginTimer(seconds, this); } + void unregisterTimer(PluginTimer *timer) override { delete timer; } + + bool available() const override { return true; } + bool enabled() const override { return true; } + +protected: + void setEnabled(bool) override {} +}; + +/* Les things enfants sont crees par le plugin, pas ecrits d'avance dans les + * reglages : on ne peut donc pas leur retirer leurs states journalises comme le + * font les suites hors ligne. Il faut un LogEngine, meme creux, sans quoi le + * ThingManager en dereference un absent des la creation du premier enfant. */ +class NullLogEngine : public LogEngine +{ +public: + explicit NullLogEngine(QObject *parent = nullptr) : LogEngine(parent) {} + + Logger *registerLogSource(const QString &name, const QStringList &tags, + Types::LoggingType loggingType, const QString &) override + { + Logger *logger = createLogger(name, tags, loggingType); + m_loggers.insert(name, logger); + return logger; + } + + void unregisterLogSource(const QString &name) override { m_loggers.remove(name); } + + LogFetchJob *fetchLogEntries(const QStringList &, const QStringList &, const QDateTime &, + const QDateTime &, const QVariantMap &, Types::SampleRate, + Qt::SortOrder, int, int) override + { + LogFetchJob *job = new LogFetchJob(this); + finishFetchJob(job, LogEntries()); + return job; + } + + bool jobsRunning() const override { return false; } + void clear(const QString &) override {} + void enable() override {} + void disable() override {} + +private: + void logEvent(Logger *, const QStringList &, const QVariantMap &) override {} + + QHash m_loggers; +}; + +class DeviceHardwareManager : public HardwareManager +{ +public: + DeviceHardwareManager(const QHostAddress &address, QObject *parent = nullptr) + : HardwareManager(parent) + , m_pluginTimerManager(new NullPluginTimerManager(this)) + , m_networkDeviceDiscovery(new DeviceDiscovery(address, this)) + {} + + Radio433 *radio433() override { return nullptr; } + PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; } + NetworkAccessManager *networkManager() override { return nullptr; } + UpnpDiscovery *upnpDiscovery() override { return nullptr; } + PlatformZeroConfController *zeroConfController() override { return nullptr; } + BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; } + MqttProvider *mqttProvider() override { return nullptr; } + I2CManager *i2cManager() override { return nullptr; } + ZigbeeHardwareResource *zigbeeResource() override { return nullptr; } + ZWaveHardwareResource *zwaveResource() override { return nullptr; } + ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; } + NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; } + +private: + NullPluginTimerManager *m_pluginTimerManager = nullptr; + DeviceDiscovery *m_networkDeviceDiscovery = nullptr; +}; + +class TestableSunSpecPlugin : public IntegrationPluginSunSpec +{ +public: + using IntegrationPlugin::setMetaData; +}; + +/* -------------------------------------------------------------------------- */ + +class SunSpecDeviceTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void cleanupTestCase(); + + void test_F3_both_connections_reach_the_device(); + void test_F3_meter_connects_only_after_the_primary_discovery(); + void test_F3_inverter_and_meter_children_share_one_parent(); + void test_F3_no_child_disappears_across_repeated_discoveries(); + void test_F3_meter_slave_id_zero_opens_no_second_connection(); + void test_F3_losing_the_meter_leaves_the_inverter_alone(); + +private: + QTemporaryDir m_pluginSearchDir; + QHostAddress m_address; + int m_port = 502; + int m_slaveId = 1; + int m_meterSlaveId = 200; + + DeviceHardwareManager *m_hardwareManager = nullptr; + ThingManagerImplementation *m_thingManager = nullptr; + NullLogEngine *m_logEngine = nullptr; + TestableSunSpecPlugin *m_plugin = nullptr; + + bool m_configured = false; + + /* Ces deux temoins sont ecrits par des lambdas connectees dans + * initTestCase() et relues par un test ulterieur : ce sont donc des membres, + * jamais des locales capturees par reference. Une locale de initTestCase() + * capturee par reference serait relachee au retour de la fonction, et les + * lambdas ecriraient ensuite dans une case de pile reutilisee par les frames + * suivantes - corruption silencieuse du test lui-meme. */ + bool m_primaryDiscovered = false; + /* Vrai des que la connexion compteur signale connected, pour verifier que + * cela n'arrive jamais avant la fin de la decouverte primaire. */ + bool m_meterConnectedBeforePrimaryDiscovery = false; + + bool skipUnlessConfigured(); + QList childrenOfKind(const ThingClassId &thingClassId) const; + void storeConnection(const ThingId &thingId, const QString &name, + const PluginId &pluginId, int meterSlaveId); + SunSpecModel *modelOn(SunSpecConnection *connection, quint16 modelId) const; +}; + +static const ThingId DeviceThingId = ThingId("5d3f8a26-41bc-4e79-a05f-9b7c2e6d1483"); +static const ThingId DeviceNoMeterThingId = ThingId("6a4e1b07-52d8-4c93-8e7b-1f39c04a5d62"); + +void SunSpecDeviceTest::storeConnection(const ThingId &thingId, const QString &name, + const PluginId &pluginId, int meterSlaveId) +{ + NymeaSettings settings(NymeaSettings::SettingsRoleThings); + settings.beginGroup("ThingConfig"); + settings.beginGroup(thingId.toString()); + settings.setValue("thingName", name); + settings.setValue("pluginid", pluginId.toString()); + settings.setValue("thingClassId", froniusConnectionThingClassId.toString()); + settings.beginGroup("Params"); + settings.setValue(froniusConnectionThingAddressParamTypeId.toString(), m_address.toString()); + settings.setValue(froniusConnectionThingPortParamTypeId.toString(), m_port); + settings.setValue(froniusConnectionThingSlaveIdParamTypeId.toString(), m_slaveId); + settings.setValue(froniusConnectionThingMeterSlaveIdParamTypeId.toString(), meterSlaveId); + settings.endGroup(); + settings.endGroup(); + settings.endGroup(); + settings.sync(); +} + +bool SunSpecDeviceTest::skipUnlessConfigured() +{ + if (m_configured) + return false; + + /* QSKIP ne peut pas etre appele depuis une fonction auxiliaire, donc le + * message est laisse a l'appelant. */ + return true; +} + +QList SunSpecDeviceTest::childrenOfKind(const ThingClassId &thingClassId) const +{ + QList found; + foreach (Thing *thing, m_thingManager->configuredThings()) { + if (thing->thingClassId() == thingClassId && thing->parentId() == DeviceThingId) + found.append(thing); + } + return found; +} + +void SunSpecDeviceTest::initTestCase() +{ + const QByteArray host = qgetenv("SUNSPEC_TEST_HOST"); + if (host.isEmpty()) { + qInfo() << "SUNSPEC_TEST_HOST is not set: every test will be skipped."; + return; + } + + m_address = QHostAddress(QString::fromUtf8(host)); + QVERIFY2(!m_address.isNull(), "SUNSPEC_TEST_HOST is not a valid address"); + + if (!qgetenv("SUNSPEC_TEST_PORT").isEmpty()) + m_port = qgetenv("SUNSPEC_TEST_PORT").toInt(); + if (!qgetenv("SUNSPEC_TEST_SLAVE_ID").isEmpty()) + m_slaveId = qgetenv("SUNSPEC_TEST_SLAVE_ID").toInt(); + if (!qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").isEmpty()) + m_meterSlaveId = qgetenv("SUNSPEC_TEST_METER_SLAVE_ID").toInt(); + + QCoreApplication::setOrganizationName("nymea-test-sunspec-device"); + + NymeaSettings thingSettings(NymeaSettings::SettingsRoleThings); + thingSettings.clear(); + NymeaSettings pluginSettings(NymeaSettings::SettingsRolePlugins); + pluginSettings.clear(); + + QVERIFY(m_pluginSearchDir.isValid()); + qputenv("NYMEA_PLUGINS_PATH", m_pluginSearchDir.path().toUtf8()); + + QFile jsonFile(PLUGIN_JSON_PATH); + QVERIFY2(jsonFile.open(QIODevice::ReadOnly), qPrintable(QString("Cannot open %1").arg(PLUGIN_JSON_PATH))); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(jsonFile.readAll(), &error); + QVERIFY2(error.error == QJsonParseError::NoError, qPrintable(error.errorString())); + + const PluginMetadata metadata(document.object()); + QVERIFY2(metadata.isValid(), "metadonnee du plugin invalide"); + + storeConnection(DeviceThingId, "Fronius", metadata.pluginId(), m_meterSlaveId); + /* Le cas degenere documente au contrat : 0 = pas de compteur derriere cet + * onduleur. Meme appareil, meme unit ID onduleur, mais aucune seconde + * connexion ne doit etre ouverte. */ + storeConnection(DeviceNoMeterThingId, "Fronius sans compteur", metadata.pluginId(), 0); + + m_hardwareManager = new DeviceHardwareManager(m_address, this); + m_logEngine = new NullLogEngine(this); + m_thingManager = new ThingManagerImplementation(m_hardwareManager, m_logEngine, QLocale(), this); + + m_plugin = new TestableSunSpecPlugin(); + m_plugin->setMetaData(metadata); + m_thingManager->registerStaticPlugin(m_plugin); + + Thing *thing = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceThingId)) != nullptr, 5000); + QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + QVERIFY(primary); + QVERIFY2(meter, "aucune connexion compteur creee : verifier SUNSPEC_TEST_METER_SLAVE_ID"); + + /* Arme avant toute attente, pour que l'ordre des deux evenements soit + * observe et pas reconstitue apres coup. */ + connect(primary, &SunSpecConnection::discoveryFinished, this, [this](bool success) { + if (success) + m_primaryDiscovered = true; + }); + connect(meter, &SunSpecConnection::connectedChanged, this, [this](bool connected) { + if (connected && !m_primaryDiscovered) + m_meterConnectedBeforePrimaryDiscovery = true; + }); + + m_configured = true; +} + +void SunSpecDeviceTest::cleanupTestCase() +{ + delete m_thingManager; + m_thingManager = nullptr; +} + +void SunSpecDeviceTest::test_F3_both_connections_reach_the_device() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + QVERIFY(primary && meter); + + QTRY_VERIFY_WITH_TIMEOUT(primary->connected(), 30000); + QTRY_VERIFY_WITH_TIMEOUT(meter->connected(), 60000); + + QCOMPARE(static_cast(primary->slaveId()), m_slaveId); + QCOMPARE(static_cast(meter->slaveId()), m_meterSlaveId); + + // Les deux ont trouve un registre de base SunSpec, chacune sur son unit ID. + QTRY_VERIFY_WITH_TIMEOUT(!primary->models().isEmpty(), 60000); + QTRY_VERIFY_WITH_TIMEOUT(!meter->models().isEmpty(), 60000); +} + +/* Q4. Deux decouvertes SunSpec simultanees seraient la charge la plus lourde + * que ce plugin fasse jamais subir a un onduleur. */ +void SunSpecDeviceTest::test_F3_meter_connects_only_after_the_primary_discovery() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + QVERIFY2(!m_meterConnectedBeforePrimaryDiscovery, + "la connexion compteur est montee avant la fin de la decouverte primaire"); +} + +/* Un seul appareil a appairer, tous les enfants sous le meme parent. */ +void SunSpecDeviceTest::test_F3_inverter_and_meter_children_share_one_parent() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseInverterThingClassId).isEmpty() + || !childrenOfKind(sunspecSplitPhaseInverterThingClassId).isEmpty() + || !childrenOfKind(sunspecSinglePhaseInverterThingClassId).isEmpty(), 60000); + + QTRY_VERIFY_WITH_TIMEOUT(!childrenOfKind(sunspecThreePhaseMeterThingClassId).isEmpty() + || !childrenOfKind(sunspecSplitPhaseMeterThingClassId).isEmpty() + || !childrenOfKind(sunspecSinglePhaseMeterThingClassId).isEmpty(), 60000); +} + +/* Le defaut le plus certain du multi unit ID : chaque connexion ne voit que la + * moitie du tableau, donc un balayage des orphelins fait sur cette moitie ferait + * disparaitre les enfants de l'autre. Une redecouverte suffit a le declencher. */ +void SunSpecDeviceTest::test_F3_no_child_disappears_across_repeated_discoveries() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + const int childrenBefore = m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(); + QVERIFY2(childrenBefore > 0, "aucun enfant a surveiller"); + + QSignalSpy disappeared(m_plugin, &IntegrationPlugin::autoThingDisappeared); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + + for (int round = 0; round < 2; round++) { + QSignalSpy primaryDone(primary, &SunSpecConnection::discoveryFinished); + primary->startDiscovery(); + QVERIFY2(primaryDone.wait(60000), "la decouverte primaire n'a pas rendu la main"); + + QSignalSpy meterDone(meter, &SunSpecConnection::discoveryFinished); + meter->startDiscovery(); + QVERIFY2(meterDone.wait(60000), "la decouverte compteur n'a pas rendu la main"); + } + + QCOMPARE(disappeared.count(), 0); + QCOMPARE(m_thingManager->configuredThings().filterByParentId(DeviceThingId).count(), childrenBefore); +} + +SunSpecModel *SunSpecDeviceTest::modelOn(SunSpecConnection *connection, quint16 modelId) const +{ + foreach (SunSpecModel *model, connection->models()) { + if (model->modelId() == modelId) + return model; + } + return nullptr; +} + +/* Le cas degenere : meterSlaveId a 0 veut dire "pas de compteur derriere cet + * onduleur". Meme appareil, meme unit ID onduleur, mais une seule connexion. + * + * On n'affirme rien sur les enfants de ce second thing : le premier a deja cree + * un enfant onduleur portant le numero de serie du simulateur, et la + * deduplication par numero de serie de autocreateSunSpecModelThing() est + * globale, pas par parent. C'est la connexion qui est mesuree ici. */ +void SunSpecDeviceTest::test_F3_meter_slave_id_zero_opens_no_second_connection() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + Thing *thing = nullptr; + QTRY_VERIFY_WITH_TIMEOUT((thing = m_thingManager->findConfiguredThing(DeviceNoMeterThingId)) != nullptr, 5000); + QTRY_COMPARE_WITH_TIMEOUT(thing->setupStatus(), Thing::ThingSetupStatusComplete, 30000); + + QVERIFY2(m_plugin->testPrimaryConnection(DeviceNoMeterThingId), + "la connexion onduleur n'a pas ete creee"); + QVERIFY2(!m_plugin->testMeterConnection(DeviceNoMeterThingId), + "meterSlaveId a 0 ne doit ouvrir aucune connexion compteur"); +} + +/* Le second defaut invisible hors ligne : l'etat connected du parent suit la + * connexion primaire seule. Un Smart Meter qui tombe ne doit ni marquer + * l'onduleur injoignable, ni effacer les donnees qu'il continue de livrer. + * + * Place en dernier, parce qu'il coupe une connexion pour de bon. */ +void SunSpecDeviceTest::test_F3_losing_the_meter_leaves_the_inverter_alone() +{ + if (skipUnlessConfigured()) + QSKIP("SUNSPEC_TEST_HOST is not set"); + + Thing *parent = m_thingManager->findConfiguredThing(DeviceThingId); + QVERIFY(parent); + QVERIFY2(parent->stateValue("connected").toBool(), "le parent n'etait pas connecte au depart"); + + QList inverters = childrenOfKind(sunspecThreePhaseInverterThingClassId); + QList meters = childrenOfKind(sunspecThreePhaseMeterThingClassId); + QVERIFY2(!inverters.isEmpty(), "pas d'enfant onduleur a surveiller"); + QVERIFY2(!meters.isEmpty(), "pas d'enfant compteur a couper"); + Thing *inverterThing = inverters.first(); + Thing *meterThing = meters.first(); + + SunSpecConnection *primary = m_plugin->testPrimaryConnection(DeviceThingId); + SunSpecConnection *meter = m_plugin->testMeterConnection(DeviceThingId); + QVERIFY(primary && meter); + + SunSpecModel *inverterModel = modelOn(primary, 113); + SunSpecModel *meterModel = modelOn(meter, 213); + QVERIFY2(inverterModel, "modele onduleur 113 introuvable sur la connexion primaire"); + QVERIFY2(meterModel, "modele compteur 213 introuvable sur la connexion compteur"); + + /* Un tour d'interrogation explicite. Ce banc remplace le minuteur du plugin + * par un objet creux - on ne veut pas interroger un appareil en boucle - or + * c'est la mise a jour de bloc qui met un enfant a connected. Sans ce tour, + * les enfants n'auraient jamais ete connectes et la coupure ci-dessous ne + * prouverait rien. */ + QSignalSpy inverterFirst(inverterModel, &SunSpecModel::blockUpdated); + QSignalSpy meterFirst(meterModel, &SunSpecModel::blockUpdated); + inverterModel->readBlockData(); + meterModel->readBlockData(); + QVERIFY2(inverterFirst.wait(30000), "pas de donnee onduleur avant la coupure"); + if (meterFirst.isEmpty()) + QVERIFY2(meterFirst.wait(30000), "pas de donnee compteur avant la coupure"); + + QVERIFY2(inverterThing->stateValue("connected").toBool(), + "l'enfant onduleur n'etait pas connecte avant la coupure"); + QVERIFY2(meterThing->stateValue("connected").toBool(), + "l'enfant compteur n'etait pas connecte avant la coupure"); + + // Coupure franche de la seule connexion compteur. + meter->disconnectDevice(); + QTRY_VERIFY_WITH_TIMEOUT(!meter->connected(), 15000); + + // Le parent reste connecte : c'est la primaire qui fait foi, jamais un ET logique. + QVERIFY2(parent->stateValue("connected").toBool(), + "le parent est passe deconnecte alors que seule la connexion compteur est tombee"); + QVERIFY(primary->connected()); + + // L'enfant onduleur n'est pas affecte... + QVERIFY2(inverterThing->stateValue("connected").toBool(), + "l'enfant onduleur a ete marque deconnecte par la chute du compteur"); + QVERIFY2(inverterThing->stateValue("currentPower").isValid(), + "les donnees onduleur ont ete effacees"); + + // ...et ses donnees continuent d'arriver. + QSignalSpy inverterUpdated(inverterModel, &SunSpecModel::blockUpdated); + inverterModel->readBlockData(); + QVERIFY2(inverterUpdated.wait(30000), + "plus aucune donnee onduleur apres la chute du compteur"); + + // Seul l'enfant compteur est marque deconnecte. + QVERIFY2(!meterThing->stateValue("connected").toBool(), + "l'enfant compteur aurait du etre marque deconnecte"); +} + +QTEST_MAIN(SunSpecDeviceTest) +#include "tst_sunspecdevice.moc" 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/logic/tst_sunspec.cpp b/sunspec/tests/logic/tst_sunspec.cpp new file mode 100644 index 0000000..04fe1c1 --- /dev/null +++ b/sunspec/tests/logic/tst_sunspec.cpp @@ -0,0 +1,875 @@ +// 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(); + + // 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; + + 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); +} + +// --------------------------------------------------------------------------- +// 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" 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..4e1a920 --- /dev/null +++ b/sunspec/tests/multiunit/tst_sunspecmultiunit.cpp @@ -0,0 +1,450 @@ +// 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_solaredge_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 ThingId SolarEdgeConnectionId = ThingId("5e2b7f49-6d38-41ca-b95e-0a4c8d71e263"); + +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 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); + 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); + storeConnection(SolarEdgeConnectionId, solarEdgeConnectionThingClassId, "SolarEdge", + 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"); +} + +/* 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. + * + * 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/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 new file mode 100644 index 0000000..9eadc09 --- /dev/null +++ b/sunspec/tests/tests.pro @@ -0,0 +1,20 @@ +TEMPLATE = subdirs + +# 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). + +# 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. + +# 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 \ + integration 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}) + + + +