Compare commits
40 Commits
main
...
landing-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e9909a6e | ||
|
|
366d332bf4 | ||
|
|
99bc606198 | ||
|
|
d08cf506ee | ||
|
|
63abc15b44 | ||
|
|
ea3b9bdd51 | ||
|
|
c459ba2f9b | ||
|
|
05d68117cd | ||
|
|
89f7bf0199 | ||
|
|
6bdb51cbf0 | ||
|
|
405b764463 | ||
|
|
5a6ad8a2e2 | ||
|
|
5db002f53b | ||
|
|
30b58dbf1e | ||
|
|
6ac9a1d019 | ||
|
|
0cdcfe433b | ||
|
|
f839f5d435 | ||
|
|
f0a3aafda0 | ||
|
|
955016cc47 | ||
|
|
1e02400acd | ||
|
|
6eb25045e2 | ||
|
|
9e0cd44af0 | ||
|
|
e1e91a19c3 | ||
|
|
a598544712 | ||
|
|
0954f66725 | ||
|
|
4f2076f504 | ||
|
|
e7b751673d | ||
|
|
3802807be7 | ||
|
|
c2eccd96e5 | ||
|
|
372be1644c | ||
|
|
89406495a8 | ||
|
|
c5c3f46f57 | ||
|
|
4ec7aa50e8 | ||
|
|
abae49e058 | ||
|
|
d87a132f23 | ||
|
|
93ecfb4ca1 | ||
|
|
bb842eea55 | ||
|
|
cbfb5df23e | ||
|
|
368c80688a | ||
|
|
b23a9a2d00 |
16
.gitignore
vendored
16
.gitignore
vendored
@ -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
|
||||
|
||||
187
AGENTS.md
187
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/* ──► experimental-silo ──► landing-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` ; `experimental-silo` a ensuite reçu le travail Fronius comme premier
|
||||
contenu, `landing-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.
|
||||
|
||||
Aucun silo n'est une branche de travail : `landing-silo` ne reçoit que ce qui a déjà survécu à
|
||||
`experimental-silo`. Le travail se fait dans `feature/*`.
|
||||
|
||||
### Critères de promotion
|
||||
|
||||
| Passage | Critère |
|
||||
|---|---|
|
||||
| commits dans `feature/*` | `verify` en local, l'agent boucle seul |
|
||||
| `feature/*` → `experimental-silo` | PR, `verify` vert sur le runner, AC de la spec couverts par des tests |
|
||||
| `experimental-silo` → `landing-silo` | build complet propre, banc nocturne vert |
|
||||
| `landing-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 `landing-silo`, puis dans `experimental-silo` |
|
||||
| dans `landing-silo`, pas dans `main` | `fix/*` depuis `landing-silo` | redescente dans `experimental-silo` |
|
||||
| seulement dans `experimental-silo` | `fix/*` depuis `experimental-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 `experimental-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 (`experimental-silo`/nightly,
|
||||
`landing-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 `experimental-silo`,
|
||||
`landing-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 `<version>`.** 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 <project>.pro && make -j"$(nproc)" # whole repository
|
||||
cd <vendor> && qmake <vendor>.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.
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
{
|
||||
"className": "AbbTerra",
|
||||
"protocol": "BOTH",
|
||||
"checkReachableRegister": "serialNumber",
|
||||
"endianness": "BigEndian",
|
||||
"stringEndianness": "BigEndian",
|
||||
"errorLimitUntilNotReachable": 3,
|
||||
"checkReachableRegister": "serialNumber",
|
||||
"queuedRequests": true,
|
||||
"queuedRequestsDelay": 50,
|
||||
"blocks": [
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<quint8>(((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<quint8>((serialNumberRaw >> 56) & 0xff);
|
||||
const quint8 ratedPowerCode = static_cast<quint8>((serialNumberRaw >> 48) & 0xff);
|
||||
const quint8 plantId = static_cast<quint8>((serialNumberRaw >> 40) & 0xff);
|
||||
const quint8 productionWeek = static_cast<quint8>((serialNumberRaw >> 24) & 0xff);
|
||||
const quint8 productionYear = static_cast<quint8>((serialNumberRaw >> 16) & 0xff);
|
||||
const quint8 uniqueHigh = static_cast<quint8>((serialNumberRaw >> 8) & 0xff);
|
||||
const quint8 uniqueLow = static_cast<quint8>(serialNumberRaw & 0xff);
|
||||
const quint8 plantId = bcdToDec(static_cast<quint8>((serialNumberRaw >> 40) & 0xff));
|
||||
const quint8 productionWeek = bcdToDec(static_cast<quint8>((serialNumberRaw >> 24) & 0xff));
|
||||
const quint8 productionYear = bcdToDec(static_cast<quint8>((serialNumberRaw >> 16) & 0xff));
|
||||
const quint8 uniqueHigh = bcdToDec(static_cast<quint8>((serialNumberRaw >> 8) & 0xff));
|
||||
const quint8 uniqueLow = bcdToDec(static_cast<quint8>(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<quint8>((firmwareVersionRaw >> 24) & 0xff)))
|
||||
.arg(bcdToDec(static_cast<quint8>((firmwareVersionRaw >> 16) & 0xff)))
|
||||
.arg(bcdToDec(static_cast<quint8>((firmwareVersionRaw >> 8) & 0xff)));
|
||||
if (maxCurrentRaw >= 6000 && maxCurrentRaw <= 32000) {
|
||||
deviceInfo.maxChargingCurrent = maxCurrentRaw / 1000.0;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -19,10 +19,8 @@
|
||||
"networkdevice"
|
||||
],
|
||||
"createMethods": [
|
||||
"discovery",
|
||||
"user"
|
||||
],
|
||||
"discoveryType": "weak",
|
||||
"paramTypes": [
|
||||
{
|
||||
"id": "9b3332aa-6c26-4399-b3dc-d7fdbe3f4420",
|
||||
|
||||
131
debian/changelog
vendored
131
debian/changelog
vendored
@ -1,3 +1,134 @@
|
||||
etm-powersync-plugins-modbus (1.15.2+etm9) trixie; urgency=medium
|
||||
|
||||
* sunspec: read Fronius float32 points big endian, the way the hardware
|
||||
emits them. byteOrder() returned ByteOrderLittleEndian on the grounds that
|
||||
"the SunSpec default is assumed"; that conflated the default of the
|
||||
specification with libnymea-sunspec's own, which is the opposite. Every
|
||||
float32 of the Fronius personality was therefore read with its two
|
||||
registers swapped. Measured on a Symo, read only: the model 213 meter on
|
||||
unit 200 serves Hz = 4248 0000, which is 50.0000 big endian and 2.3777e-41
|
||||
swapped. Reproduced on the bench: a meter importing 2.7 kW reported as
|
||||
exporting 107 MW, with the arbiter switching every load on, and the thing
|
||||
still reporting itself connected with no Modbus error. The meter sign fix
|
||||
only becomes observable once this is right. solarEdge already forces big
|
||||
endian and the generic vendor keeps its user parameter; neither changes.
|
||||
* sunspec: add tools/read-float-order.py, a read-only probe that decodes Hz
|
||||
and PhVphA both ways so the word order can be settled by measurement
|
||||
rather than assumption.
|
||||
|
||||
-- Patrick Schurig <patrick@etm-schurig.eu> Fri, 07 Aug 2026 07:00:00 +0200
|
||||
|
||||
etm-powersync-plugins-modbus (1.15.2+etm8) trixie; urgency=medium
|
||||
|
||||
* sunspec: ask the connection thing directly instead of resolving it by id.
|
||||
The battery presence gate went through isFroniusChild(), which looks the
|
||||
parent up in myThings(). During the initial setup the parent is not
|
||||
registered yet, the lookup answers false, the Fronius gate is bypassed and
|
||||
the phantom storage child is created after all. Caught on the bench, not
|
||||
by the unit suite, which covers the decision and not the wiring. See
|
||||
sunspec/DIVERGENCES.md D-7.
|
||||
|
||||
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 05 Aug 2026 19:20:00 +0200
|
||||
|
||||
etm-powersync-plugins-modbus (1.15.2+etm7) trixie; urgency=medium
|
||||
|
||||
* sunspec: correct the grid meter sign on Fronius. onMeterBlockUpdated()
|
||||
negated the power of every meter model regardless of vendor, but the
|
||||
Fronius Smart Meter already reports in the nymea convention, where a
|
||||
negative value is feed-in. The negation is now undone for meters sitting
|
||||
under a froniusConnection and only for those; generic and solarEdge keep
|
||||
their previous behaviour. Established on a Primo GEN24 3.0, whose single
|
||||
phase inverter gives the direction reference: the plugin reported the
|
||||
inverter's own phase as importing while the two phases carrying no source
|
||||
read as exporting. The arbiter concluded "insufficient surplus" and left
|
||||
the water heater and the heat pump off while the site was exporting.
|
||||
The energy counters were already correct and are left alone.
|
||||
* sunspec: stop creating a phantom battery on Fronius. A hybrid GEN24
|
||||
publishes model 124 whether or not a battery is wired to it, so a storage
|
||||
child was created unconditionally and sat on batteryCritical forever. The
|
||||
child is now created only when a physical quantity says a battery is
|
||||
there. The state of charge is deliberately not used: 0% cannot be told
|
||||
apart from a real but empty battery. See sunspec/DIVERGENCES.md D-6, the
|
||||
battery-present side is still inferred.
|
||||
|
||||
-- Patrick Schurig <patrick@etm-schurig.eu> Wed, 05 Aug 2026 15:58:00 +0200
|
||||
|
||||
etm-powersync-plugins-modbus (1.15.2+etm6) trixie; urgency=medium
|
||||
|
||||
* sunspec: package the vendored SunSpec plugin as nymea-plugin-sunspec-etm01.
|
||||
The plugin was added to the build by the vendoring commit but never
|
||||
packaged, so dh_missing found its .so in debian/tmp with nowhere to install
|
||||
it and aborted the whole source package: no binary package at all came out
|
||||
of this branch, not even for the four unrelated plugins.
|
||||
* sunspec: add the Fronius vendor. A GEN24 answers for the inverter on one
|
||||
unit ID and for its Smart Meter on another, behind the same host and port,
|
||||
so one froniusConnection thing now carries two SunSpecConnection objects.
|
||||
The meter connection is brought up only once the primary has finished
|
||||
discovering, and the parent connected state follows the primary alone.
|
||||
* sunspec: correct the storage setpoint chain - charge and discharge rates
|
||||
read back from InWRte/OutWRte instead of the read-only gradients, the rate
|
||||
range widened to -100..100 so forced charging can be expressed at all,
|
||||
enableDischarging reconciled from StorCtl_Mod, and the revert timer armed
|
||||
with every setpoint so one left behind by a stopped HEMS expires.
|
||||
* debian: add libnymea-sunspec-dev to Build-Depends. Upstream does not need
|
||||
it because it builds libnymea-sunspec from the same source tree; this
|
||||
repository vendors only the plugin and links against the installed library.
|
||||
* The new binary package conflicts with, replaces and provides
|
||||
nymea-plugin-sunspec: it ships the same plugin file and the same pluginId
|
||||
as the upstream package this repository already mirrors.
|
||||
|
||||
-- ETM-Schurig SARL <contact@etm-schurig.eu> Sun, 02 Aug 2026 12:05:00 +0200
|
||||
|
||||
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 <contact@etm-schurig.eu> 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 <contact@etm-schurig.eu> 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 <contact@etm-schurig.eu> 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 <contact@etm-schurig.eu> Fri, 03 Jul 2026 12:00:00 +0200
|
||||
|
||||
etm-powersync-plugins-modbus (1.15.2+etm1) trixie; urgency=medium
|
||||
|
||||
* Build 1.15,2
|
||||
|
||||
23
debian/control
vendored
23
debian/control
vendored
@ -10,6 +10,7 @@ Build-Depends: debhelper,
|
||||
libnymea-dev,
|
||||
nymea-dev-tools:native,
|
||||
libnymea-modbus-dev,
|
||||
libnymea-sunspec-dev,
|
||||
qt6-base-dev,
|
||||
qt6-base-dev-tools,
|
||||
qt6-serialport-dev,
|
||||
@ -53,3 +54,25 @@ Description: PowerSync integration plugin for V2C Trydan EV charger (Modbus TCP)
|
||||
system. Implements the evcharger interface: charge enable/disable, current
|
||||
setpoint, plugged-in / charging state, house and solar power telemetry.
|
||||
Status: Partial — not yet validated on real hardware.
|
||||
|
||||
Package: nymea-plugin-sunspec-etm01
|
||||
Architecture: any
|
||||
Section: libs
|
||||
Depends: ${shlibs:Depends},
|
||||
${misc:Depends},
|
||||
Conflicts: nymea-plugin-sunspec
|
||||
Replaces: nymea-plugin-sunspec
|
||||
Provides: nymea-plugin-sunspec
|
||||
Description: SunSpec Modbus devices, ETM fork with Fronius support - vendored
|
||||
nymea integration plugin for SunSpec compatible solar inverters, meters and
|
||||
storage over Modbus TCP. ETM fork of the upstream plugin, adding the Fronius
|
||||
vendor: a GEN24 answers for the inverter and for its Smart Meter on separate
|
||||
unit IDs behind one host and port, so one connection thing carries two.
|
||||
.
|
||||
Ships the same plugin file and the same pluginId as the upstream
|
||||
nymea-plugin-sunspec, which it therefore conflicts with and replaces. The
|
||||
-etm01 suffix marks the fork; the name goes back to nymea-plugin-sunspec if
|
||||
the Fronius vendor is ever merged upstream.
|
||||
.
|
||||
Status: Partial - verified against a SunSpec simulator only, never on a real
|
||||
inverter.
|
||||
|
||||
1
debian/nymea-plugin-sunspec-etm01.install
vendored
Normal file
1
debian/nymea-plugin-sunspec-etm01.install
vendored
Normal file
@ -0,0 +1 @@
|
||||
usr/lib/*/nymea/plugins/libnymea_integrationpluginsunspec.so
|
||||
@ -7,6 +7,7 @@ PLUGIN_DIRS = \
|
||||
eastron \
|
||||
abbb2x \
|
||||
abbterra \
|
||||
sunspec \
|
||||
v2c
|
||||
|
||||
|
||||
|
||||
437
specs/fronius-sunspec.md
Normal file
437
specs/fronius-sunspec.md
Normal file
@ -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=<sysroot> 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.
|
||||
35
sunspec.pri
35
sunspec.pri
@ -1,7 +1,34 @@
|
||||
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 <sysroot> contient usr/include/nymea-sunspec et usr/lib/<triplet>.
|
||||
#
|
||||
# Le paquet 1.15.2 installe les en-tetes des modeles sous
|
||||
# <includedir>/home/jenkins/nymea-plugins-modbus/libnymea-sunspec/models/
|
||||
# (le chemin de build Jenkins a fuite dans le .deb). Le plugin fait
|
||||
# #include <models/sunspecmodelfactory.h>
|
||||
# qui ne resout donc pas avec le seul -I<includedir>. 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
|
||||
SUNSPEC_LIBDIR = $$NYMEA_SUNSPEC_PATH/usr/lib/$$QMAKE_HOST.arch-linux-gnu
|
||||
INCLUDEPATH += $$SUNSPEC_INCLUDEDIR
|
||||
LIBS += -L$$SUNSPEC_LIBDIR -lnymea-sunspec1
|
||||
# Build de developpement contre un sysroot : la bibliotheque n'est pas dans
|
||||
# le cache de l'editeur de liens dynamique, il faut donc l'embarquer. Ce
|
||||
# rpath n'existe pas dans le build paquet, qui passe par pkg-config.
|
||||
QMAKE_RPATHDIR += $$SUNSPEC_LIBDIR
|
||||
}
|
||||
|
||||
SUNSPEC_MODELS_WORKAROUND = $$SUNSPEC_INCLUDEDIR/home/jenkins/nymea-plugins-modbus/libnymea-sunspec
|
||||
exists($$SUNSPEC_MODELS_WORKAROUND/models): INCLUDEPATH += $$SUNSPEC_MODELS_WORKAROUND
|
||||
|
||||
245
sunspec/DIVERGENCES.md
Normal file
245
sunspec/DIVERGENCES.md
Normal file
@ -0,0 +1,245 @@
|
||||
# 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`.
|
||||
|
||||
---
|
||||
|
||||
## D-5 — le signe du compteur est corrigé côté Fronius seulement (MESURÉ)
|
||||
|
||||
**Contexte.** `onMeterBlockUpdated()` négociait le signe de façon **générique** :
|
||||
tous les modèles compteur (201, 202, 203, 204, 211…214) faisaient
|
||||
`-meter->watts()`. Il n'existait aucun mapping spécifique à un fabricant.
|
||||
|
||||
**L'écart.** Sur Fronius cette négation est fausse : le Smart Meter émet déjà
|
||||
dans la convention nymea (négatif = injection). Le correctif n'annule la
|
||||
négation que lorsque le compteur est enfant d'une `froniusConnection` ; les
|
||||
vendors generic et solarEdge gardent la négation historique, verrouillée par
|
||||
`test_AC_16_generic_meter_keeps_the_historical_negation`.
|
||||
|
||||
**Preuve.** Relevé du 02/08/2026 sur un Primo GEN24 3.0, onduleur **monophasé**
|
||||
(modèle 101) et Smart Meter TS 65A-3 (modèle **203**, pas 213). L'onduleur
|
||||
n'injecte que sur la phase A ; le plugin rapportait phase A = +1003 W
|
||||
(soutirage) et phases B et C à −33 W et −262 W, soit une injection sur deux
|
||||
phases ne portant aucune source. En inversant, le bilan se referme : production
|
||||
1190 W = charge phase A 187 W + export 1003 W ; import B+C 294 W ; consommation
|
||||
481 W ; export réseau 709 W contre 708 W mesuré. Corroboration indépendante :
|
||||
sur la même fenêtre, le compteur d'énergie *import* de l'appareil reste à 0
|
||||
pendant que l'*export* s'incrémente. Conséquence observée :
|
||||
`[Arbitre] "chauffe-eau" → "Surplus insuffisant (-842 W)"` alors que le site
|
||||
exportait ~842 W.
|
||||
|
||||
**Non touché.** Les compteurs d'énergie (`totalEnergyConsumed` /
|
||||
`totalEnergyProduced`) sont **corrects** et ne sont pas permutés : ce sont eux
|
||||
qui servent de témoin à charge ci-dessus.
|
||||
|
||||
**Comment lever.** Déterminer si la négation générique est fausse pour tous les
|
||||
fabricants ou seulement pour Fronius demanderait un compteur d'un autre vendor
|
||||
sur banc. Tant que ce n'est pas tranché, le générique ne bouge pas.
|
||||
|
||||
---
|
||||
|
||||
## D-6 — présence batterie : le côté « batterie présente » n'est pas validé (INFÉRÉ)
|
||||
|
||||
**Contexte.** Un GEN24 hybride publie le modèle 124 même sans batterie câblée.
|
||||
Le plugin créait donc un enfant storage systématiquement, d'où batterie fantôme
|
||||
et `batteryCritical` permanent.
|
||||
|
||||
**L'écart.** `FroniusRules::hasBattery()` exige désormais `InBatV > 0` ou
|
||||
`StorAval > 0` pour créer l'enfant, et seulement sous une `froniusConnection`.
|
||||
Le SoC est écarté (0 % ne se distingue pas d'une batterie vide) ainsi que les
|
||||
registres de consigne (`WChaMax`… gardent leurs défauts sans batterie).
|
||||
|
||||
**Ce qui est établi.** Le côté « absente » est confirmé sur matériel : le GEN24
|
||||
des beaux-parents, sans batterie, exposait le modèle 124 avec `ChaSt = Off` et
|
||||
un SoC de 0, et nymea journalisait « There is no energy storage available to
|
||||
consider » pendant que l'enfant restait visible.
|
||||
|
||||
**Ce qui ne l'est pas.** `InBatV` et `StorAval` n'étant pas exposés en states
|
||||
nymea, leurs valeurs sur ce GEN24 sans batterie **n'ont pas pu être relevées**,
|
||||
et aucun GEN24 **avec** batterie n'était joignable. L'hypothèse « une batterie
|
||||
installée porte une tension au repos » est donc **INFÉRÉE des deux côtés**.
|
||||
|
||||
**Comment lever.** Relever en lecture seule le bloc 124 d'un GEN24 équipé d'une
|
||||
batterie et vérifier `InBatV`/`StorAval` non nuls. **À faire avant tout
|
||||
déploiement** : un discriminateur trop strict ferait disparaître une vraie
|
||||
batterie chez un client.
|
||||
|
||||
---
|
||||
|
||||
## D-7 — la présence batterie ne peut pas être vérifiée par la suite logique (MESURÉ)
|
||||
|
||||
**Contexte.** La première version du garde de D-6 demandait
|
||||
`isFroniusChild(thing->id())`, qui résout le parent par `myThings().findById()`.
|
||||
|
||||
**L'écart.** Pendant le setup initial, la connexion parente n'est **pas encore
|
||||
enregistrée** dans `myThings()` : la recherche répond faux, le garde Fronius est
|
||||
contourné, et l'enfant storage est créé malgré `InBatV = StorAval = 0`. Trace
|
||||
nymead correspondante :
|
||||
`ThingManager: Invalid parent thing id … Not adding thing to the system.`
|
||||
|
||||
Le garde interroge désormais directement le `thing` de connexion que la fonction
|
||||
tient déjà (`thing->thingClassId() == froniusConnectionThingClassId`), sans
|
||||
aucune recherche. Même traitement pour le signe compteur, qui utilise le
|
||||
`parentThing` déjà résolu et non nul.
|
||||
|
||||
**Ce que cela apprend.** La suite `tests/logic` ne peut pas attraper ce défaut :
|
||||
elle couvre la décision pure (`FroniusRules`), pas le câblage du plugin, c'est à
|
||||
dire *quel prédicat est interrogé et à quel moment du cycle de vie*. Seul un
|
||||
essai en boucle fermée contre un simulateur le révèle. Toute modification du
|
||||
garde de présence doit donc être rejouée sur banc, pas seulement en unitaire.
|
||||
|
||||
**Comment lever.** Étendre `tests/plugin`, qui instancie réellement le plugin, à
|
||||
un scénario de setup initial avec un modèle 124 à zéro. Hors périmètre ici.
|
||||
|
||||
---
|
||||
|
||||
## D-8 — ordre des mots des float32 : le défaut de la bibliothèque n'est pas celui de la spec (MESURÉ)
|
||||
|
||||
**Contexte.** `FroniusRules::byteOrder()` renvoyait `ByteOrderLittleEndian`, avec
|
||||
pour justification « non documenté par le constructeur, on suppose le défaut
|
||||
SunSpec ».
|
||||
|
||||
**L'écart.** La supposition confondait deux « défauts ». Un float32 SunSpec
|
||||
occupe deux registres, **mot de poids fort d'abord**. Or
|
||||
`SunSpecDataPoint::convertToUInt32` lit `registers.at(0)` puis `.at(1)` en
|
||||
`ByteOrderBigEndian`, et l'**inverse** en `ByteOrderLittleEndian` — qui est le
|
||||
défaut de libnymea-sunspec. Garder ce défaut revient à lire tous les float32 de
|
||||
la personnalité Fronius avec leurs deux mots permutés.
|
||||
|
||||
**Mesure.** Relevé du 2026-08-07 sur un Fronius Symo réel, en lecture seule
|
||||
(`sunspec/tools/read-float-order.py`) :
|
||||
|
||||
| point | registres bruts | big endian | mots inversés |
|
||||
|---|---|---|---|
|
||||
| compteur 213 u200 `Hz` | `4248 0000` | **50,0000** | 2,3777e−41 |
|
||||
| compteur 213 u200 `PhVphA` | `436a 8000` | **234,5000** | −2,4184e−41 |
|
||||
| onduleur 113 u1 `Hz` | `4247 fc48` | **49,9964** | −4,1592e+36 |
|
||||
|
||||
**Ce que cela coûtait.** Pas un décalage : une absurdité. Reproduit sur banc avec
|
||||
le simulateur émettant l'ordre du matériel — un compteur **important 2,7 kW** a
|
||||
été rapporté comme **exportant 107 MW**, `Current load on root meter:
|
||||
-1.07621e+08W`, et l'arbitre a enclenché toutes les charges sur un « Surplus PV
|
||||
107622496 W ». Un tel appareil se présente `connected = true`, sans aucune
|
||||
erreur Modbus.
|
||||
|
||||
**Ordre des correctifs.** `meterPowerSign()` (D-5) reste juste, mais n'est
|
||||
**observable** qu'une fois l'ordre des mots corrigé : corriger le signe d'un
|
||||
dénormal ne produit rien.
|
||||
|
||||
**Non touché.** Le vendor solarEdge force déjà big endian sur son propre chemin ;
|
||||
le vendor générique conserve son paramètre `endianness` réglable par
|
||||
l'utilisateur. Aucun des deux n'est modifié.
|
||||
|
||||
**Verrou.** `test_AC_6` affirme désormais `ByteOrderBigEndian`, et
|
||||
`test_AC_18_swapped_words_yield_denormals` fige le raisonnement : les registres
|
||||
réels lus à l'envers donnent un dénormal, jamais une mesure plausible.
|
||||
29
sunspec/README.md
Normal file
29
sunspec/README.md
Normal file
@ -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
|
||||
45
sunspec/VENDORED.md
Normal file
45
sunspec/VENDORED.md
Normal file
@ -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.
|
||||
206
sunspec/froniusrules.cpp
Normal file
206
sunspec/froniusrules.cpp
Normal file
@ -0,0 +1,206 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
*
|
||||
* Copyright (C) 2026, ETM-PowerSync
|
||||
*
|
||||
* This file is part of nymea.
|
||||
*
|
||||
* nymea is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* nymea. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
double meterPowerSign(bool froniusMeter)
|
||||
{
|
||||
/* The Fronius meter is already in the nymea convention, so the generic
|
||||
* negation has to be undone for it, and only for it. */
|
||||
return froniusMeter ? 1.0 : -1.0;
|
||||
}
|
||||
|
||||
bool hasBattery(double batteryVoltage, double availableCharge)
|
||||
{
|
||||
/* Strictly positive only: zero is the no-battery reading, and a negative
|
||||
* value is the library's way of surfacing a not-implemented point rather
|
||||
* than a real measurement. */
|
||||
return batteryVoltage > 0.0 || availableCharge > 0.0;
|
||||
}
|
||||
|
||||
SunSpecDataPoint::ByteOrder byteOrder()
|
||||
{
|
||||
/* Big endian is what the hardware emits, and what SunSpec specifies. The
|
||||
* library's default is the opposite; see the header for the measurement. */
|
||||
return SunSpecDataPoint::ByteOrderBigEndian;
|
||||
}
|
||||
|
||||
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<Write> 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<ModuleReading> &readings)
|
||||
{
|
||||
double total = 0;
|
||||
foreach (const ModuleReading &reading, readings) {
|
||||
if (classifyBlock(reading.inputIdString) == BlockRolePvString)
|
||||
total += reading.dcPower;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
double batteryPower(const QList<ModuleReading> &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;
|
||||
}
|
||||
|
||||
}
|
||||
214
sunspec/froniusrules.h
Normal file
214
sunspec/froniusrules.h
Normal file
@ -0,0 +1,214 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
|
||||
*
|
||||
* Copyright (C) 2026, ETM-PowerSync
|
||||
*
|
||||
* This file is part of nymea.
|
||||
*
|
||||
* nymea is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* nymea is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* nymea. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef FRONIUSRULES_H
|
||||
#define FRONIUSRULES_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <sunspecdatapoint.h>
|
||||
|
||||
#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);
|
||||
|
||||
/* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get
|
||||
* the nymea smartmeter convention, where positive is import and negative is
|
||||
* export.
|
||||
*
|
||||
* The Fronius Smart Meter already reports in that convention: negative W is
|
||||
* feed-in. The generic path negates, so on a Fronius the surplus came out with
|
||||
* the wrong sign and the arbiter refused to run loads while the site was
|
||||
* exporting.
|
||||
*
|
||||
* Established on hardware, not from the manufacturer's documentation. On a
|
||||
* Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin
|
||||
* reported phase A at +1003 W (import) while phases B and C read -33 W and
|
||||
* -262 W (export). Exporting on two phases carrying no source is impossible.
|
||||
* With the sign flipped the balance closes exactly: production 1190 W = phase A
|
||||
* load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws
|
||||
* 481 W and the site exports 709 W against 708 W measured. The meter's own
|
||||
* energy counters agree: over the same window its import counter stayed at
|
||||
* zero while its export counter advanced.
|
||||
*
|
||||
* Only the instantaneous power is affected. The energy counters map correctly
|
||||
* already and must not be swapped.
|
||||
*
|
||||
* The generic default is left untouched: other vendors keep the negation. */
|
||||
double meterPowerSign(bool froniusMeter);
|
||||
|
||||
/* Whether a model 124 block is backed by a physically installed battery.
|
||||
*
|
||||
* A hybrid GEN24 publishes model 124 whether or not a battery is wired to it,
|
||||
* so the presence of the model proves nothing. Creating a storage child anyway
|
||||
* gives the customer a phantom battery and a permanent batteryCritical alarm:
|
||||
* observed on hardware, a GEN24 with no battery reported ChaSt = Off and a
|
||||
* state of charge of 0, and nymea's own energy engine then logged "There is no
|
||||
* energy storage available to consider" while the child stayed visible.
|
||||
*
|
||||
* The discriminator is deliberately a *physical* quantity. The state of charge
|
||||
* is refused because 0% cannot be told apart from a real but empty battery, and
|
||||
* the setpoint registers (WChaMax and friends) are refused because they keep
|
||||
* their defaults with no battery attached.
|
||||
*
|
||||
* INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or
|
||||
* StorAval is expected to be strictly positive whenever one is present, and
|
||||
* both zero when none is. The absent side matches what the battery-less GEN24
|
||||
* showed and is reproduced on the simulator. The present side could NOT be
|
||||
* confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on
|
||||
* a GEN24 with a battery before this ships. See DIVERGENCES.md. */
|
||||
bool hasBattery(double batteryVoltage, double availableCharge);
|
||||
|
||||
/* Word order of the float32 points, measured on hardware.
|
||||
*
|
||||
* This used to read "INFERRED: not documented by the manufacturer, so the
|
||||
* SunSpec default is assumed. To be confirmed on the bench, then on hardware."
|
||||
* It has now been confirmed, and the assumption was wrong: what was assumed to
|
||||
* be "the SunSpec default" was in fact libnymea-sunspec's own default, which is
|
||||
* the opposite of the specification.
|
||||
*
|
||||
* A float32 spans two registers. SunSpec puts the high word first.
|
||||
* SunSpecDataPoint::convertToUInt32 reads registers.at(0) then .at(1) for
|
||||
* ByteOrderBigEndian, and the reverse for ByteOrderLittleEndian — and little
|
||||
* endian is the library's default. Keeping that default on a Fronius means
|
||||
* reading every float32 with its two words swapped.
|
||||
*
|
||||
* Measured 2026-08-07 on a Fronius Symo, read only:
|
||||
* meter 213, unit 200 : Hz = 4248 0000 -> 50.0000 big endian
|
||||
* PhVphA = 436a 8000 -> 234.5000
|
||||
* inverter 113, unit 1: Hz = 4247 fc48 -> 49.9964
|
||||
* Swapped, those same registers give 2.4e-41 and -4.2e+36: denormals, the
|
||||
* signature of a wrong interpretation rather than a measurement.
|
||||
*
|
||||
* What it costs to get this wrong is not a small offset. Reproduced on the
|
||||
* bench with the simulator emitting the hardware's order: a meter importing
|
||||
* 2.7 kW was reported as exporting 107 MW, and the arbiter switched every load
|
||||
* on. Correcting the SIGN of a denormal changes nothing, so meterPowerSign()
|
||||
* only becomes observable once this is right.
|
||||
*
|
||||
* The SolarEdge vendor already forces big endian on its own path; the generic
|
||||
* vendor keeps its user-settable parameter. Neither is affected. */
|
||||
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<ModuleReading> &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<ModuleReading> &readings);
|
||||
|
||||
}
|
||||
|
||||
#endif // FRONIUSRULES_H
|
||||
2203
sunspec/integrationpluginsunspec.cpp
Normal file
2203
sunspec/integrationpluginsunspec.cpp
Normal file
File diff suppressed because it is too large
Load Diff
211
sunspec/integrationpluginsunspec.h
Normal file
211
sunspec/integrationpluginsunspec.h
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef INTEGRATIONPLUGINSUNSPEC_H
|
||||
#define INTEGRATIONPLUGINSUNSPEC_H
|
||||
|
||||
#include <integrations/integrationplugin.h>
|
||||
#include <network/networkdevicemonitor.h>
|
||||
#include <plugintimer.h>
|
||||
|
||||
#include <sunspecconnection.h>
|
||||
#include <models/sunspecmodelfactory.h>
|
||||
|
||||
#include "sunspecthing.h"
|
||||
#include "sunspecstoragelogic.h"
|
||||
#include "froniusrules.h"
|
||||
#include "extern-plugininfo.h"
|
||||
|
||||
#include <QUuid>
|
||||
|
||||
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<ThingClassId, ParamTypeId> m_connectionDiscoverySlaveIdParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_connectionMacAddressParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_connectionAddressParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_connectionHostNameParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_connectionPortParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_connectionSlaveIdParamTypeIds;
|
||||
|
||||
// SunSpec thing params map
|
||||
QHash<ThingClassId, ParamTypeId> m_modelIdParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_modbusAddressParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_manufacturerParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_deviceModelParamTypeIds;
|
||||
QHash<ThingClassId, ParamTypeId> m_serialNumberParamTypeIds;
|
||||
|
||||
PluginTimer *m_refreshTimer = nullptr;
|
||||
|
||||
QHash<Thing *, NetworkDeviceMonitor *> 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<ThingId, SunSpecConnection *> 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<ThingId, SunSpecConnection *> m_meterConnections;
|
||||
|
||||
QHash<Thing *, SunSpecThing *> m_sunSpecThings;
|
||||
|
||||
QHash<Thing *, SunSpecModel *> m_sunSpecInverters;
|
||||
QHash<Thing *, SunSpecModel *> m_sunSpecMeters;
|
||||
QHash<Thing *, SunSpecModel *> 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<SunSpecConnection *> 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<ThingId, QDateTime> 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<SunSpecConnection *> connectionsFor(const ThingId &parentId) const;
|
||||
QList<SunSpecConnection *> 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<Thing *> 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<void()> &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
|
||||
|
||||
1849
sunspec/integrationpluginsunspec.json
Normal file
1849
sunspec/integrationpluginsunspec.json
Normal file
File diff suppressed because it is too large
Load Diff
14
sunspec/meta.json
Normal file
14
sunspec/meta.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "SunSpec",
|
||||
"tagline": "Connect to SunSpec devices.",
|
||||
"icon": "sunspec.png",
|
||||
"stability": "consumer",
|
||||
"offline": true,
|
||||
"technologies": [
|
||||
"network",
|
||||
"modbus"
|
||||
],
|
||||
"categories": [
|
||||
"energy"
|
||||
]
|
||||
}
|
||||
253
sunspec/solaredgebattery.cpp
Normal file
253
sunspec/solaredgebattery.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#include "solaredgebattery.h"
|
||||
#include "extern-plugininfo.h"
|
||||
|
||||
#include <sunspecdatapoint.h>
|
||||
#include <sunspecconnection.h>
|
||||
|
||||
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<quint16> 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<quint16> 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<quint16> values = unit.values();
|
||||
|
||||
qCDebug(dcSunSpec()) << "SolarEdgeBattery: Received second block data" << m_modbusStartRegister + offset << values.length();
|
||||
qCDebug(dcSunSpec()) << "SolarEdgeBattery:" << SunSpecDataPoint::registersToString(values);
|
||||
QVector<quint16> 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<BatteryStatus>(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;
|
||||
}
|
||||
131
sunspec/solaredgebattery.h
Normal file
131
sunspec/solaredgebattery.h
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef SOLAREDGEBATTERY_H
|
||||
#define SOLAREDGEBATTERY_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
|
||||
#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
|
||||
BIN
sunspec/sunspec.png
Normal file
BIN
sunspec/sunspec.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
18
sunspec/sunspec.pro
Normal file
18
sunspec/sunspec.pro
Normal file
@ -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
|
||||
235
sunspec/sunspecdiscovery.cpp
Normal file
235
sunspec/sunspecdiscovery.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#include "sunspecdiscovery.h"
|
||||
|
||||
#include <sunspecmodel.h>
|
||||
#include <models/sunspecmodelfactory.h>
|
||||
#include <models/sunspeccommonmodel.h>
|
||||
|
||||
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::Result> 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<SunSpecConnection *> 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<SunSpecCommonModel *>(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();
|
||||
}
|
||||
77
sunspec/sunspecdiscovery.h
Normal file
77
sunspec/sunspecdiscovery.h
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef SUNSPECDISCOVERY_H
|
||||
#define SUNSPECDISCOVERY_H
|
||||
|
||||
#include <QQueue>
|
||||
#include <QObject>
|
||||
#include <QDateTime>
|
||||
|
||||
#include <sunspecconnection.h>
|
||||
#include <network/networkdevicediscovery.h>
|
||||
|
||||
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<Result> results() const;
|
||||
|
||||
void addCustomDiscoveryPort(quint16 port);
|
||||
void startDiscovery();
|
||||
|
||||
signals:
|
||||
void discoveryFinished();
|
||||
|
||||
private:
|
||||
NetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
|
||||
QList<quint16> m_scanPorts;
|
||||
quint16 m_slaveId = 1;
|
||||
SunSpecDataPoint::ByteOrder m_byteOrder;
|
||||
|
||||
QDateTime m_startDateTime;
|
||||
QHash<QHostAddress, QQueue<SunSpecConnection *>> m_pendingConnectionAttempts;
|
||||
QHash<SunSpecConnection *, QTimer *> m_connectionTimers;
|
||||
QList<SunSpecConnection *> m_connections;
|
||||
NetworkDeviceInfos m_networkDeviceInfos;
|
||||
QList<Result> m_results;
|
||||
|
||||
void testNextConnection(const QHostAddress &address);
|
||||
|
||||
void checkNetworkDevice(const QHostAddress &address);
|
||||
void cleanupConnection(SunSpecConnection *connection);
|
||||
|
||||
void finishDiscovery();
|
||||
};
|
||||
|
||||
#endif // SUNSPECDISCOVERY_H
|
||||
126
sunspec/sunspecstoragelogic.cpp
Normal file
126
sunspec/sunspecstoragelogic.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#include "sunspecstoragelogic.h"
|
||||
|
||||
#include <QtMath>
|
||||
|
||||
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<Write> &setpoints, quint16 revertTime)
|
||||
{
|
||||
SetpointPlan plan;
|
||||
|
||||
QVector<Write> 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<double>(revertTime)});
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
}
|
||||
116
sunspec/sunspecstoragelogic.h
Normal file
116
sunspec/sunspecstoragelogic.h
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef SUNSPECSTORAGELOGIC_H
|
||||
#define SUNSPECSTORAGELOGIC_H
|
||||
|
||||
#include <QVector>
|
||||
|
||||
#include <models/sunspecstoragemodel.h>
|
||||
|
||||
/* 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<Write> 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<Register> 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<Write> &setpoints, quint16 revertTime);
|
||||
|
||||
}
|
||||
|
||||
#endif // SUNSPECSTORAGELOGIC_H
|
||||
70
sunspec/sunspecthing.cpp
Normal file
70
sunspec/sunspecthing.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#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
|
||||
}
|
||||
63
sunspec/sunspecthing.h
Normal file
63
sunspec/sunspecthing.h
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
#ifndef SUNSPECTHING_H
|
||||
#define SUNSPECTHING_H
|
||||
|
||||
#include <QObject>
|
||||
|
||||
#include "extern-plugininfo.h"
|
||||
|
||||
#include <integrations/integrationplugin.h>
|
||||
|
||||
#include <sunspecmodel.h>
|
||||
#include <sunspecconnection.h>
|
||||
|
||||
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
|
||||
19
sunspec/test/sunspec_server.sh
Executable file
19
sunspec/test/sunspec_server.sh
Executable file
@ -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
|
||||
75
sunspec/tests/integration/integration.pro
Normal file
75
sunspec/tests/integration/integration.pro
Normal file
@ -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=<adresse> [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
|
||||
584
sunspec/tests/integration/tst_sunspecdevice.cpp
Normal file
584
sunspec/tests/integration/tst_sunspecdevice.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
/* 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 <QtTest>
|
||||
#include <QCoreApplication>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <nymeasettings.h>
|
||||
#include <hardwaremanager.h>
|
||||
#include <plugintimer.h>
|
||||
#include <logging/logengine.h>
|
||||
#include <logging/logger.h>
|
||||
#include <network/networkdevicediscovery.h>
|
||||
#include <network/networkdevicemonitor.h>
|
||||
#include <integrations/thingmanagerimplementation.h>
|
||||
#include <integrations/thingsetupinfo.h>
|
||||
|
||||
#include <sunspecconnection.h>
|
||||
|
||||
#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<QString, Logger *> 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<Thing *> 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<Thing *> SunSpecDeviceTest::childrenOfKind(const ThingClassId &thingClassId) const
|
||||
{
|
||||
QList<Thing *> 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<int>(primary->slaveId()), m_slaveId);
|
||||
QCOMPARE(static_cast<int>(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<Thing *> inverters = childrenOfKind(sunspecThreePhaseInverterThingClassId);
|
||||
QList<Thing *> 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"
|
||||
29
sunspec/tests/logic/logic.pro
Normal file
29
sunspec/tests/logic/logic.pro
Normal file
@ -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
|
||||
1006
sunspec/tests/logic/tst_sunspec.cpp
Normal file
1006
sunspec/tests/logic/tst_sunspec.cpp
Normal file
File diff suppressed because it is too large
Load Diff
64
sunspec/tests/multiunit/multiunit.pro
Normal file
64
sunspec/tests/multiunit/multiunit.pro
Normal file
@ -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
|
||||
450
sunspec/tests/multiunit/tst_sunspecmultiunit.cpp
Normal file
450
sunspec/tests/multiunit/tst_sunspecmultiunit.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
/* F3 - un thing froniusConnection, deux unit IDs.
|
||||
*
|
||||
* Le GEN24 repond pour l'onduleur sur l'unit ID 1 et pour le Smart Meter sur
|
||||
* l'unit ID 200 par defaut, derriere le meme host:port. Le plugin ouvre donc
|
||||
* deux SunSpecConnection pour un seul thing.
|
||||
*
|
||||
* Ce que cette suite couvre : la creation, le nombre, les unit IDs, et surtout
|
||||
* la destruction - une connexion compteur qui survit a son thing garde une
|
||||
* socket ouverte et des signaux pointant vers un Thing detruit.
|
||||
*
|
||||
* Ce qu'elle ne couvre pas, faute de pair Modbus : la decouverte, la creation
|
||||
* des things enfants, le balayage des orphelins et l'agregation de l'etat
|
||||
* connected. Voir ../integration.
|
||||
*
|
||||
* Binaire distinct de ../plugin : la suite F1 y verifie des compteurs d'appels
|
||||
* globaux que des things configures supplementaires feraient mentir.
|
||||
*/
|
||||
|
||||
#include <QtTest>
|
||||
#include <QCoreApplication>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <nymeasettings.h>
|
||||
#include <hardwaremanager.h>
|
||||
#include <plugintimer.h>
|
||||
#include <network/networkdevicediscovery.h>
|
||||
#include <network/networkdevicemonitor.h>
|
||||
#include <integrations/thingmanagerimplementation.h>
|
||||
#include <integrations/thingsetupinfo.h>
|
||||
|
||||
#include <sunspecconnection.h>
|
||||
|
||||
#include "integrationpluginsunspec.h"
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
* Doublures du materiel. Le moniteur ne connait aucune adresse et n'est jamais
|
||||
* joignable : aucune connexion Modbus n'est donc tentee.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
class FakeNetworkDeviceMonitor : public NetworkDeviceMonitor
|
||||
{
|
||||
public:
|
||||
explicit FakeNetworkDeviceMonitor(QObject *parent = nullptr) : NetworkDeviceMonitor(parent) {}
|
||||
|
||||
MacAddress macAddress() const override { return MacAddress(); }
|
||||
QString hostName() const override { return QString(); }
|
||||
QHostAddress address() const override { return QHostAddress(); }
|
||||
NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; }
|
||||
NetworkDeviceInfo networkDeviceInfo() const override { return NetworkDeviceInfo(); }
|
||||
bool reachable() const override { return false; }
|
||||
QDateTime lastSeen() const override { return QDateTime(); }
|
||||
uint pingRetries() const override { return 0; }
|
||||
void setPingRetries(uint) override {}
|
||||
};
|
||||
|
||||
class FakeNetworkDeviceDiscovery : public NetworkDeviceDiscovery
|
||||
{
|
||||
public:
|
||||
explicit FakeNetworkDeviceDiscovery(QObject *parent = nullptr) : NetworkDeviceDiscovery(parent) {}
|
||||
|
||||
int registerMonitorCount = 0;
|
||||
int unregisterMonitorCount = 0;
|
||||
|
||||
NetworkDeviceDiscoveryReply *discover() override { return nullptr; }
|
||||
bool running() const override { return false; }
|
||||
|
||||
NetworkDeviceMonitor *registerMonitor(Thing *) override
|
||||
{
|
||||
registerMonitorCount++;
|
||||
return new FakeNetworkDeviceMonitor(this);
|
||||
}
|
||||
|
||||
void unregisterMonitor(NetworkDeviceMonitor *monitor) override
|
||||
{
|
||||
unregisterMonitorCount++;
|
||||
delete monitor;
|
||||
}
|
||||
|
||||
PingReply *ping(const QHostAddress &, uint) override { return nullptr; }
|
||||
PingReply *ping(const QString &, uint) override { return nullptr; }
|
||||
MacAddressDatabaseReply *lookupMacAddress(const QString &) override { return nullptr; }
|
||||
MacAddressDatabaseReply *lookupMacAddress(const MacAddress &) override { return nullptr; }
|
||||
bool sendArpRequest(const QHostAddress &) override { return false; }
|
||||
NetworkDeviceInfos cache() const override { return NetworkDeviceInfos(); }
|
||||
|
||||
bool available() const override { return true; }
|
||||
bool enabled() const override { return true; }
|
||||
|
||||
protected:
|
||||
void setEnabled(bool) override {}
|
||||
};
|
||||
|
||||
class FakePluginTimer : public PluginTimer
|
||||
{
|
||||
public:
|
||||
explicit FakePluginTimer(int seconds, QObject *parent = nullptr)
|
||||
: PluginTimer(parent), m_interval(seconds) {}
|
||||
|
||||
int interval() const override { return m_interval; }
|
||||
int currentTick() const override { return 0; }
|
||||
bool running() const override { return m_running; }
|
||||
|
||||
void reset() override {}
|
||||
void start() override { m_running = true; }
|
||||
void stop() override { m_running = false; }
|
||||
void pause() override {}
|
||||
void resume() override {}
|
||||
|
||||
private:
|
||||
int m_interval = 0;
|
||||
bool m_running = false;
|
||||
};
|
||||
|
||||
class FakePluginTimerManager : public PluginTimerManager
|
||||
{
|
||||
public:
|
||||
explicit FakePluginTimerManager(QObject *parent = nullptr) : PluginTimerManager(parent) {}
|
||||
|
||||
PluginTimer *registerTimer(int seconds) override { return new FakePluginTimer(seconds, this); }
|
||||
void unregisterTimer(PluginTimer *timer) override { delete timer; }
|
||||
|
||||
bool available() const override { return true; }
|
||||
bool enabled() const override { return true; }
|
||||
|
||||
protected:
|
||||
void setEnabled(bool) override {}
|
||||
};
|
||||
|
||||
class FakeHardwareManager : public HardwareManager
|
||||
{
|
||||
public:
|
||||
explicit FakeHardwareManager(QObject *parent = nullptr)
|
||||
: HardwareManager(parent)
|
||||
, m_pluginTimerManager(new FakePluginTimerManager(this))
|
||||
, m_networkDeviceDiscovery(new FakeNetworkDeviceDiscovery(this))
|
||||
{}
|
||||
|
||||
Radio433 *radio433() override { return nullptr; }
|
||||
PluginTimerManager *pluginTimerManager() override { return m_pluginTimerManager; }
|
||||
NetworkAccessManager *networkManager() override { return nullptr; }
|
||||
UpnpDiscovery *upnpDiscovery() override { return nullptr; }
|
||||
PlatformZeroConfController *zeroConfController() override { return nullptr; }
|
||||
BluetoothLowEnergyManager *bluetoothLowEnergyManager() override { return nullptr; }
|
||||
MqttProvider *mqttProvider() override { return nullptr; }
|
||||
I2CManager *i2cManager() override { return nullptr; }
|
||||
ZigbeeHardwareResource *zigbeeResource() override { return nullptr; }
|
||||
ZWaveHardwareResource *zwaveResource() override { return nullptr; }
|
||||
ModbusRtuHardwareResource *modbusRtuResource() override { return nullptr; }
|
||||
NetworkDeviceDiscovery *networkDeviceDiscovery() override { return m_networkDeviceDiscovery; }
|
||||
|
||||
FakeNetworkDeviceDiscovery *monitors() const { return m_networkDeviceDiscovery; }
|
||||
|
||||
private:
|
||||
FakePluginTimerManager *m_pluginTimerManager = nullptr;
|
||||
FakeNetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr;
|
||||
};
|
||||
|
||||
class TestableSunSpecPlugin : public IntegrationPluginSunSpec
|
||||
{
|
||||
public:
|
||||
using IntegrationPlugin::setMetaData;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
class SunSpecMultiUnitTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void initTestCase();
|
||||
void cleanupTestCase();
|
||||
|
||||
void test_F3_fronius_connection_creates_two_connections();
|
||||
void test_F3_meter_connection_uses_the_meter_slave_id();
|
||||
void test_F3_meter_connection_stays_idle_until_the_primary_discovered();
|
||||
void test_F3_meter_slave_id_zero_creates_only_one_connection();
|
||||
void test_F3_meter_slave_id_colliding_with_the_inverter_is_refused();
|
||||
void test_F3_generic_connection_creates_no_meter_connection();
|
||||
void test_F3_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<int>(primary->slaveId()), InverterSlaveId);
|
||||
QCOMPARE(static_cast<int>(meter->slaveId()), MeterSlaveId);
|
||||
|
||||
// Meme appareil, donc meme hote et meme port.
|
||||
QCOMPARE(meter->hostAddress(), primary->hostAddress());
|
||||
QCOMPARE(meter->port(), primary->port());
|
||||
}
|
||||
|
||||
/* Q4 : la connexion compteur n'est montee qu'une fois la decouverte primaire
|
||||
* terminee. Deux decouvertes SunSpec simultanees seraient la charge la plus
|
||||
* lourde que ce plugin fasse jamais subir a un onduleur, et le cout de la
|
||||
* prudence n'est que de quelques secondes d'appairage.
|
||||
*
|
||||
* Ici le moniteur n'est jamais joignable, donc la primaire ne decouvre jamais :
|
||||
* la connexion compteur doit exister sans etre connectee. */
|
||||
void SunSpecMultiUnitTest::test_F3_meter_connection_stays_idle_until_the_primary_discovered()
|
||||
{
|
||||
SunSpecConnection *meter = m_plugin->testMeterConnection(FroniusWithMeterId);
|
||||
QVERIFY(meter);
|
||||
QVERIFY2(!meter->connected(),
|
||||
"la connexion compteur ne doit pas etre montee avant la fin de la decouverte primaire");
|
||||
}
|
||||
|
||||
/* 0 est la facon documentee de dire "pas de compteur derriere cet onduleur". */
|
||||
void SunSpecMultiUnitTest::test_F3_meter_slave_id_zero_creates_only_one_connection()
|
||||
{
|
||||
Thing *thing = awaitThing(FroniusWithoutMeterId);
|
||||
QVERIFY(thing);
|
||||
|
||||
QVERIFY(m_plugin->testPrimaryConnection(FroniusWithoutMeterId));
|
||||
QVERIFY2(!m_plugin->testMeterConnection(FroniusWithoutMeterId),
|
||||
"meterSlaveId a 0 ne doit ouvrir aucune connexion compteur");
|
||||
}
|
||||
|
||||
/* Une seconde socket sur l'unit ID deja servi par l'onduleur redecouvrirait les
|
||||
* memes modeles et dupliquerait chaque thing enfant. */
|
||||
void SunSpecMultiUnitTest::test_F3_meter_slave_id_colliding_with_the_inverter_is_refused()
|
||||
{
|
||||
Thing *thing = awaitThing(FroniusCollidingId);
|
||||
QVERIFY(thing);
|
||||
|
||||
QVERIFY(m_plugin->testPrimaryConnection(FroniusCollidingId));
|
||||
QVERIFY2(!m_plugin->testMeterConnection(FroniusCollidingId),
|
||||
"un meterSlaveId egal au slaveId de l'onduleur doit etre refuse");
|
||||
}
|
||||
|
||||
/* Non regression : le multi unit ID est une particularite Fronius. */
|
||||
void SunSpecMultiUnitTest::test_F3_generic_connection_creates_no_meter_connection()
|
||||
{
|
||||
Thing *thing = awaitThing(GenericConnectionId);
|
||||
QVERIFY(thing);
|
||||
|
||||
QVERIFY(m_plugin->testPrimaryConnection(GenericConnectionId));
|
||||
QVERIFY2(!m_plugin->testMeterConnection(GenericConnectionId),
|
||||
"le vendor generique garde exactement une connexion par thing");
|
||||
}
|
||||
|
||||
/* 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"
|
||||
62
sunspec/tests/plugin/plugin.pro
Normal file
62
sunspec/tests/plugin/plugin.pro
Normal file
@ -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
|
||||
388
sunspec/tests/plugin/tst_sunspecplugin.cpp
Normal file
388
sunspec/tests/plugin/tst_sunspecplugin.cpp
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
|
||||
|
||||
/* 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 <QtTest>
|
||||
#include <QCoreApplication>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#include <nymeasettings.h>
|
||||
#include <hardwaremanager.h>
|
||||
#include <plugintimer.h>
|
||||
#include <network/networkdevicediscovery.h>
|
||||
#include <network/networkdevicemonitor.h>
|
||||
#include <integrations/thingmanagerimplementation.h>
|
||||
#include <integrations/thingsetupinfo.h>
|
||||
|
||||
#include "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"
|
||||
20
sunspec/tests/tests.pro
Normal file
20
sunspec/tests/tests.pro
Normal file
@ -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
|
||||
189
sunspec/tools/read-float-order.py
Normal file
189
sunspec/tools/read-float-order.py
Normal file
@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Quel ordre de mots un onduleur SunSpec émet-il pour ses float32 ?
|
||||
|
||||
La spécification SunSpec dit big endian, mot de poids fort en premier. Mais
|
||||
libnymea-sunspec décode par défaut `ByteOrderLittleEndian`, c'est à dire
|
||||
`registers.at(1)` PUIS `registers.at(0)` — mot de poids faible d'abord
|
||||
(sunspecdatapoint.cpp, convertToUInt32). Le vendor Fronius du plugin garde ce
|
||||
défaut. Si l'appareil émet en big endian, le plugin lit des dénormaux (~1e-41)
|
||||
et aucune correction de SIGNE n'y changera quoi que ce soit.
|
||||
|
||||
Ce script tranche la question par la mesure, sur deux points dont la valeur
|
||||
attendue est connue d'avance et non ambiguë :
|
||||
- Hz : ~50 Hz
|
||||
- PhVphA : ~230..240 V
|
||||
Un ordre donne ces valeurs, l'autre donne des dénormaux. Il n'y a pas de doute
|
||||
possible entre les deux.
|
||||
|
||||
LECTURE SEULE : ce script n'émet QUE des fonctions Modbus 3 (read holding
|
||||
registers). Aucune écriture, aucun code fonction d'écriture n'est implémenté.
|
||||
|
||||
python3 read-float-order.py 192.168.3.230
|
||||
python3 read-float-order.py 192.168.3.230 --inverter-unit 1 --meter-unit 200
|
||||
|
||||
Bibliothèque standard uniquement : rien à installer.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
SUNS = (0x5375, 0x6E53)
|
||||
|
||||
# Offsets comptés depuis le registre ID du modèle (les données commencent à +2).
|
||||
# Chaque point float32 occupe DEUX registres, d'où le pas de 2.
|
||||
INV_F = {"W": 22, "Hz": 24, "PhVphA": 16, "PhVphB": 18, "PhVphC": 20, "DCW": 38}
|
||||
MET_F = {"W": 28, "WphA": 30, "WphB": 32, "WphC": 34,
|
||||
"Hz": 26, "PhV": 10, "PhVphA": 12, "PhVphB": 14, "PhVphC": 16}
|
||||
|
||||
INVERTER_FLOAT = (111, 112, 113)
|
||||
METER_FLOAT = (211, 212, 213, 214)
|
||||
INVERTER_INT = (101, 102, 103)
|
||||
METER_INT = (201, 202, 203, 204)
|
||||
|
||||
|
||||
def read_holding(sock, unit, addr, count):
|
||||
"""Fonction 3, et rien d'autre."""
|
||||
sock.sendall(struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count))
|
||||
hdr = b""
|
||||
while len(hdr) < 8:
|
||||
chunk = sock.recv(8 - len(hdr))
|
||||
if not chunk:
|
||||
raise IOError("connexion fermée (en-tête)")
|
||||
hdr += chunk
|
||||
_, _, length, _, fc = struct.unpack(">HHHBB", hdr)
|
||||
body = b""
|
||||
while len(body) < length - 2:
|
||||
chunk = sock.recv(length - 2 - len(body))
|
||||
if not chunk:
|
||||
raise IOError("connexion fermée (corps)")
|
||||
body += chunk
|
||||
if fc & 0x80:
|
||||
raise IOError("exception modbus %d en lisant %d registres à %d"
|
||||
% (body[0], count, addr))
|
||||
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]]))
|
||||
|
||||
|
||||
def decode(r0, r1, low_word_first):
|
||||
hi, lo = (r1, r0) if low_word_first else (r0, r1)
|
||||
return struct.unpack(">f", struct.pack(">HH", hi, lo))[0]
|
||||
|
||||
|
||||
def find_base(sock, unit):
|
||||
for base in (40000, 50000, 0):
|
||||
try:
|
||||
if tuple(read_holding(sock, unit, base, 2)) == SUNS:
|
||||
return base
|
||||
except IOError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def walk(sock, unit, base):
|
||||
"""(model_id, adresse du registre ID, longueur) pour toute la chaîne."""
|
||||
out, addr = [], base + 2
|
||||
while addr < base + 5000:
|
||||
mid, mlen = read_holding(sock, unit, addr, 2)
|
||||
if mid == 0xFFFF:
|
||||
return out
|
||||
out.append((mid, addr, mlen))
|
||||
addr += 2 + mlen
|
||||
return out
|
||||
|
||||
|
||||
def verdict(hz_low, hz_big):
|
||||
"""Quel ordre donne une fréquence réseau plausible ?"""
|
||||
def plausible(v):
|
||||
return 45.0 <= v <= 65.0
|
||||
if plausible(hz_low) and not plausible(hz_big):
|
||||
return "MOT DE POIDS FAIBLE D'ABORD (ce que le plugin lit déjà)"
|
||||
if plausible(hz_big) and not plausible(hz_low):
|
||||
return ("BIG ENDIAN, conforme à la spec SunSpec — un plugin qui garde le "
|
||||
"défaut de libnymea-sunspec lira des dénormaux")
|
||||
if hz_low == 0.0 and hz_big == 0.0:
|
||||
return "INDÉCIDABLE : registres à zéro, aucun ordre ne se distingue"
|
||||
return "INDÉCIDABLE : aucune des deux lectures ne donne 45..65 Hz"
|
||||
|
||||
|
||||
def probe(host, port, unit, wanted_float, wanted_int, table, points, role):
|
||||
print("=" * 74)
|
||||
print("%s — unité %d" % (role, unit))
|
||||
print("=" * 74)
|
||||
sock = socket.create_connection((host, port), timeout=10)
|
||||
try:
|
||||
base = find_base(sock, unit)
|
||||
if base is None:
|
||||
print(" aucun marqueur « SunS » : rien à cette unité.\n")
|
||||
return
|
||||
chain = walk(sock, unit, base)
|
||||
print(" base %d · chaîne : %s" % (
|
||||
base, ", ".join("%d@%d(L=%d)" % (m, a, l) for m, a, l in chain)))
|
||||
|
||||
found = [c for c in chain if c[0] in wanted_float]
|
||||
if not found:
|
||||
ints = [c for c in chain if c[0] in wanted_int]
|
||||
if ints:
|
||||
print(" modèle %d : ENTIER+SF, pas de float32 — la question de "
|
||||
"l'ordre des mots ne se pose pas ici.\n" % ints[0][0])
|
||||
else:
|
||||
print(" aucun modèle attendu dans cette chaîne.\n")
|
||||
return
|
||||
|
||||
mid, id_addr, mlen = found[0]
|
||||
print(" modèle float %d, registre ID %d, longueur %d\n" % (mid, id_addr, mlen))
|
||||
print(" %-8s %-9s %-14s %14s %16s %s" % (
|
||||
"point", "registre", "brut (hex)", "mot faible 1er", "big endian", "lecture"))
|
||||
print(" " + "-" * 86)
|
||||
hz = None
|
||||
for name in points:
|
||||
off = table[name]
|
||||
addr = id_addr + off
|
||||
r0, r1 = read_holding(sock, unit, addr, 2)
|
||||
low, big = decode(r0, r1, True), decode(r0, r1, False)
|
||||
if name == "Hz":
|
||||
hz = (low, big)
|
||||
note = "nul — n'apprend rien" if (r0 == 0 and r1 == 0) else ""
|
||||
print(" %-8s %-9d %-14s %14.4f %16.4e %s" % (
|
||||
name, addr, "%04X %04X" % (r0, r1), low, big, note))
|
||||
print()
|
||||
if hz:
|
||||
print(" >>> %s" % verdict(*hz))
|
||||
print()
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Ordre des mots des float32 SunSpec — LECTURE SEULE.")
|
||||
ap.add_argument("host")
|
||||
ap.add_argument("--port", type=int, default=502)
|
||||
ap.add_argument("--inverter-unit", type=int, default=1)
|
||||
ap.add_argument("--meter-unit", type=int, default=200)
|
||||
args = ap.parse_args()
|
||||
|
||||
# Le COMPTEUR d'abord : c'est lui qui tranche. Il mesure le réseau en
|
||||
# permanence, donc ses registres portent des valeurs même onduleur en veille.
|
||||
# Un onduleur au repos sert un bloc à zéro, et zéro se décode en 0.0 dans les
|
||||
# DEUX ordres : ça n'apprend rien.
|
||||
probe(args.host, args.port, args.meter_unit, METER_FLOAT, METER_INT,
|
||||
MET_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W", "WphA"),
|
||||
"COMPTEUR — mesure décisive")
|
||||
probe(args.host, args.port, args.inverter_unit, INVERTER_FLOAT, INVERTER_INT,
|
||||
INV_F, ("Hz", "PhVphA", "PhVphB", "PhVphC", "W"),
|
||||
"ONDULEUR — confirmation, nul si en veille")
|
||||
|
||||
print("Lecture : la colonne qui donne ~50 Hz et ~230..240 V est l'ordre que")
|
||||
print("l'appareil émet. L'autre affiche des dénormaux (~1e-41) : c'est la")
|
||||
print("signature d'une mauvaise interprétation, pas une vraie mesure.")
|
||||
print()
|
||||
print("Si « big endian » est la bonne colonne, tout lecteur qui garde le défaut")
|
||||
print("de libnymea-sunspec (mot faible d'abord) lira ces modèles de travers.")
|
||||
print("Le vendor Fronius du plugin force big endian depuis 1.15.2+etm9.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
167
sunspec/tools/read-model124.py
Normal file
167
sunspec/tools/read-model124.py
Normal file
@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Read-only dump of the SunSpec storage block (model 124) of an inverter.
|
||||
|
||||
Answers one question: does this inverter have a battery physically installed?
|
||||
See sunspec/DIVERGENCES.md D-6 — the "battery present" side of the presence
|
||||
discriminator is still inferred and needs a reading from real hardware.
|
||||
|
||||
Standard library only: no pymodbus, no pysunspec2, nothing to install.
|
||||
It only ever issues function code 3 (read holding registers). It never writes.
|
||||
|
||||
python3 read-model124.py 192.168.1.50
|
||||
python3 read-model124.py fronius.local --unit 1
|
||||
|
||||
Report back the InBatV, StorAval and ChaSt lines.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
|
||||
SUNS = (0x5375, 0x6E53) # "SunS"
|
||||
|
||||
# Model 124, in order, from the SunSpec storage model definition.
|
||||
# (name, offset, kind, scale-factor name or None)
|
||||
POINTS = [
|
||||
("WChaMax", 0, "u16", "WChaMax_SF"),
|
||||
("WChaGra", 1, "u16", "WChaDisChaGra_SF"),
|
||||
("WDisChaGra", 2, "u16", "WChaDisChaGra_SF"),
|
||||
("StorCtl_Mod", 3, "u16", None),
|
||||
("VAChaMax", 4, "u16", "VAChaMax_SF"),
|
||||
("MinRsvPct", 5, "u16", "MinRsvPct_SF"),
|
||||
("ChaState", 6, "u16", "ChaState_SF"),
|
||||
("StorAval", 7, "u16", "StorAval_SF"),
|
||||
("InBatV", 8, "u16", "InBatV_SF"),
|
||||
("ChaSt", 9, "u16", None),
|
||||
("OutWRte", 10, "i16", "InOutWRte_SF"),
|
||||
("InWRte", 11, "i16", "InOutWRte_SF"),
|
||||
("ChaGriSet", 15, "u16", None),
|
||||
]
|
||||
|
||||
SCALE_FACTORS = {
|
||||
"WChaMax_SF": 16, "WChaDisChaGra_SF": 17, "VAChaMax_SF": 18,
|
||||
"MinRsvPct_SF": 19, "ChaState_SF": 20, "StorAval_SF": 21,
|
||||
"InBatV_SF": 22, "InOutWRte_SF": 23,
|
||||
}
|
||||
|
||||
CHA_ST = {1: "Off", 2: "Empty", 3: "Discharging", 4: "Charging",
|
||||
5: "Full", 6: "Holding", 7: "Testing"}
|
||||
|
||||
NOT_IMPLEMENTED_U16 = 0xFFFF
|
||||
NOT_IMPLEMENTED_I16 = 0x8000
|
||||
|
||||
|
||||
def read_holding(sock, unit, addr, count):
|
||||
req = struct.pack(">HHHBBHH", 1, 0, 6, unit, 3, addr, count)
|
||||
sock.sendall(req)
|
||||
hdr = b""
|
||||
while len(hdr) < 8:
|
||||
chunk = sock.recv(8 - len(hdr))
|
||||
if not chunk:
|
||||
raise IOError("connection closed while reading header")
|
||||
hdr += chunk
|
||||
_, _, length, _, fc = struct.unpack(">HHHBB", hdr)
|
||||
body = b""
|
||||
while len(body) < length - 2:
|
||||
chunk = sock.recv(length - 2 - len(body))
|
||||
if not chunk:
|
||||
raise IOError("connection closed while reading body")
|
||||
body += chunk
|
||||
if fc & 0x80:
|
||||
raise IOError("modbus exception %d reading %d registers at %d"
|
||||
% (body[0], count, addr))
|
||||
return list(struct.unpack(">%dH" % (body[0] // 2), body[1:1 + body[0]]))
|
||||
|
||||
|
||||
def find_base(sock, unit):
|
||||
for base in (40000, 50000, 0):
|
||||
try:
|
||||
if tuple(read_holding(sock, unit, base, 2)) == SUNS:
|
||||
return base
|
||||
except IOError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def find_model(sock, unit, base, wanted):
|
||||
addr = base + 2
|
||||
chain = []
|
||||
while addr < base + 5000:
|
||||
mid, mlen = read_holding(sock, unit, addr, 2)
|
||||
if mid == 0xFFFF:
|
||||
return None, chain
|
||||
chain.append(mid)
|
||||
if mid == wanted:
|
||||
return addr + 2, chain
|
||||
addr += 2 + mlen
|
||||
return None, chain
|
||||
|
||||
|
||||
def scaled(raw, kind, exponent):
|
||||
if kind == "i16" and raw > 0x7FFF:
|
||||
raw -= 0x10000
|
||||
if exponent is None:
|
||||
return float(raw)
|
||||
if exponent > 0x7FFF:
|
||||
exponent -= 0x10000
|
||||
return raw * (10.0 ** exponent)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Read-only dump of SunSpec model 124.")
|
||||
ap.add_argument("host")
|
||||
ap.add_argument("--port", type=int, default=502)
|
||||
ap.add_argument("--unit", type=int, default=1)
|
||||
args = ap.parse_args()
|
||||
|
||||
sock = socket.create_connection((args.host, args.port), timeout=10)
|
||||
try:
|
||||
base = find_base(sock, args.unit)
|
||||
if base is None:
|
||||
print("No SunSpec 'SunS' marker at 40000, 50000 or 0 on unit %d." % args.unit)
|
||||
return 2
|
||||
print("SunSpec base register: %d (unit %d)" % (base, args.unit))
|
||||
|
||||
data, chain = find_model(sock, args.unit, base, 124)
|
||||
print("Models present: %s" % ", ".join(str(m) for m in chain))
|
||||
if data is None:
|
||||
print("\nNo model 124 on this inverter: it publishes no storage block at all.")
|
||||
return 0
|
||||
|
||||
regs = read_holding(sock, args.unit, data, 24)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
print("\nModel 124 raw block at %d:" % data)
|
||||
print(" " + " ".join("%04X" % r for r in regs))
|
||||
|
||||
print("\n%-14s %10s %s" % ("point", "value", "raw"))
|
||||
for name, off, kind, sf in POINTS:
|
||||
raw = regs[off]
|
||||
sentinel = NOT_IMPLEMENTED_I16 if kind == "i16" else NOT_IMPLEMENTED_U16
|
||||
if raw == sentinel:
|
||||
print("%-14s %10s 0x%04X" % (name, "n/impl", raw))
|
||||
continue
|
||||
value = scaled(raw, kind, regs[SCALE_FACTORS[sf]] if sf else None)
|
||||
extra = " (%s)" % CHA_ST.get(raw, "?") if name == "ChaSt" else ""
|
||||
print("%-14s %10.3f 0x%04X%s" % (name, value, raw, extra))
|
||||
|
||||
in_bat_v = scaled(regs[8], "u16", regs[SCALE_FACTORS["InBatV_SF"]])
|
||||
stor_aval = scaled(regs[7], "u16", regs[SCALE_FACTORS["StorAval_SF"]])
|
||||
print("\n--- what this decides -------------------------------------------")
|
||||
print("InBatV = %.3f V" % in_bat_v)
|
||||
print("StorAval = %.3f Ah" % stor_aval)
|
||||
if in_bat_v > 0 or stor_aval > 0:
|
||||
print("=> reads as BATTERY PRESENT")
|
||||
else:
|
||||
print("=> reads as NO BATTERY")
|
||||
print("\nOn an inverter that really does have a battery, both being zero would")
|
||||
print("mean the discriminator is wrong and would hide a real battery. That is")
|
||||
print("exactly the case to report back.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1,976 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1">
|
||||
<context>
|
||||
<name>IntegrationPluginSunSpec</name>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="147"/>
|
||||
<source>The discovery is not available.</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="354"/>
|
||||
<source>The SunSpec connection is not connected.</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="471"/>
|
||||
<source>Single Phase Inverter</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="475"/>
|
||||
<source>Split Phase Inverter</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="479"/>
|
||||
<source>Three Phase Inverter</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="483"/>
|
||||
<source>Single Phase Meter</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="487"/>
|
||||
<source>Split Phase Meter</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="493"/>
|
||||
<source>Three Phase Meter</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="496"/>
|
||||
<source>Storage</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="556"/>
|
||||
<source>The SunSpec discovery finished with errors. Please make sure this is a SunSpec device.</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="890"/>
|
||||
<source>Ground fault</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="894"/>
|
||||
<source>DC over voltage</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="898"/>
|
||||
<source>AC disconnect</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="902"/>
|
||||
<source>DC disconnect</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="906"/>
|
||||
<source>Grid disconnect</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="910"/>
|
||||
<source>Cabinet open</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="914"/>
|
||||
<source>Manual shutdown</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="918"/>
|
||||
<source>Over temperature</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="922"/>
|
||||
<source>Over frequency</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="926"/>
|
||||
<source>Under frequency</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="930"/>
|
||||
<source>AC over voltage</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="934"/>
|
||||
<source>AC under voltage</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="938"/>
|
||||
<source>Blown string fuse</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="942"/>
|
||||
<source>Under temperature</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="946"/>
|
||||
<source>Memory loss</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="950"/>
|
||||
<source>Hardware test failure</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../integrationpluginsunspec.cpp" line="954"/>
|
||||
<source>No error</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>SunSpec</name>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="219"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="222"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="225"/>
|
||||
<source>AC energy</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="228"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="231"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="234"/>
|
||||
<source>AC power</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="237"/>
|
||||
<source>Available energy</source>
|
||||
<extracomment>The name of the StateType ({7ac7ccd1-b5d1-488b-a99b-98ef000b7d03}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="240"/>
|
||||
<source>Average temperature</source>
|
||||
<extracomment>The name of the StateType ({f46298e5-d06c-4ebe-869a-d9feff1a4689}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="243"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="246"/>
|
||||
<source>Battery critical</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="249"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="252"/>
|
||||
<source>Battery level</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="255"/>
|
||||
<source>Battery status</source>
|
||||
<extracomment>The name of the StateType ({9c24b27a-3ea1-4db1-8868-6c999aad0a18}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="258"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="261"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="264"/>
|
||||
<source>Cabinet temperature</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="267"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="270"/>
|
||||
<source>Charging</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="273"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="276"/>
|
||||
<source>Charging rate</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="279"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="282"/>
|
||||
<source>Charging state</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="285"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="288"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="291"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="294"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="297"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="300"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="303"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="306"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="309"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="312"/>
|
||||
<source>Connected</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="315"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="318"/>
|
||||
<source>Current power phase A</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="321"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="324"/>
|
||||
<source>Current power phase B</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="327"/>
|
||||
<source>Current power phase C</source>
|
||||
<extracomment>The name of the StateType ({ba4e94de-9136-48a7-aad8-3d5cd0e7d968}) of ThingClass sunspecThreePhaseMeter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="330"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="333"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="336"/>
|
||||
<source>DC current</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="339"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="342"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="345"/>
|
||||
<source>DC power</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="348"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="351"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="354"/>
|
||||
<source>DC voltage</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="357"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="360"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="363"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="366"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="369"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="372"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="375"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="378"/>
|
||||
<source>Device model</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="381"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="384"/>
|
||||
<source>Discharging</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="387"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="390"/>
|
||||
<source>Discharging rate</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="393"/>
|
||||
<source>Enable charging</source>
|
||||
<extracomment>The name of the ActionType ({1f530f79-c0d2-466b-90e1-79149e34d92f}) of ThingClass sunspecStorage</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="396"/>
|
||||
<source>Enable discharging</source>
|
||||
<extracomment>The name of the ActionType ({bc99a159-815a-40ab-a6e8-b46f315305f7}) of ThingClass sunspecStorage</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="399"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="402"/>
|
||||
<source>Energy consumed phase A</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="405"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="408"/>
|
||||
<source>Energy consumed phase B</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="411"/>
|
||||
<source>Energy consumed phase C</source>
|
||||
<extracomment>The name of the StateType ({2a12a991-d6c8-4665-bcc8-ea2685f7f0d4}) of ThingClass sunspecThreePhaseMeter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="414"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="417"/>
|
||||
<source>Energy produced phase A</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="420"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="423"/>
|
||||
<source>Energy produced phase B</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="426"/>
|
||||
<source>Energy produced phase C</source>
|
||||
<extracomment>The name of the StateType ({a26790c0-7378-471c-80ba-67021352415d}) of ThingClass sunspecThreePhaseMeter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="429"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="432"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="435"/>
|
||||
<source>Error</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="438"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="441"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="444"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="447"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="450"/>
|
||||
<source>Frequency</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="453"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="456"/>
|
||||
<source>Grid charging</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="459"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="462"/>
|
||||
<source>IP address</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="465"/>
|
||||
<source>Instantaneous current</source>
|
||||
<extracomment>The name of the StateType ({89f58155-ca0c-48a8-aa7f-6e4d1b1bad36}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="468"/>
|
||||
<source>Instantaneous power</source>
|
||||
<extracomment>The name of the StateType ({caccf9fd-0f83-4dd5-ae48-a497f13d2a2e}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="471"/>
|
||||
<source>Instantaneous voltage</source>
|
||||
<extracomment>The name of the StateType ({9d17defb-fc6e-4c10-af53-14b796222a14}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="474"/>
|
||||
<source>Line frequency</source>
|
||||
<extracomment>The name of the StateType ({874f5e4a-a009-4c28-b211-2af90a24b2ac}) of ThingClass sunspecSplitPhaseInverter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="477"/>
|
||||
<source>Line to Neutral AC Voltage</source>
|
||||
<extracomment>The name of the StateType ({0ac79508-07c3-4d01-97a3-6edf121bdf32}) of ThingClass sunspecSplitPhaseMeter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="480"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="483"/>
|
||||
<source>MAC address</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="486"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="489"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="492"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="495"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="498"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="501"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="504"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="507"/>
|
||||
<source>Manufacturer</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="510"/>
|
||||
<source>Maximum energy</source>
|
||||
<extracomment>The name of the StateType ({d8cc799d-9709-4342-8766-52ff5f12aecc}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="513"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="516"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="519"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="522"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="525"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="528"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="531"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="534"/>
|
||||
<source>Modbus address</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="537"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="540"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="543"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="546"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="549"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="552"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="555"/>
|
||||
<source>Model</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="558"/>
|
||||
<source>Number of retries</source>
|
||||
<extracomment>The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {9a4bfe01-315f-4ee7-98a9-f16b08ba12ad})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="561"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="564"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="567"/>
|
||||
<source>Operating state</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="570"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="573"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="576"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="579"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="582"/>
|
||||
<source>Phase A current</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="585"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="588"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="591"/>
|
||||
<source>Phase A voltage</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="594"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="597"/>
|
||||
<source>Phase AN voltage</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="600"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="603"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="606"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="609"/>
|
||||
<source>Phase B current</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="612"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="615"/>
|
||||
<source>Phase B voltage</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="618"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="621"/>
|
||||
<source>Phase BN voltage</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="624"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="627"/>
|
||||
<source>Phase C current</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="630"/>
|
||||
<source>Phase C voltage</source>
|
||||
<extracomment>The name of the StateType ({fbd673cf-8891-4aef-b112-2829f5fcfe06}) of ThingClass sunspecThreePhaseMeter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="633"/>
|
||||
<source>Phase CN voltage</source>
|
||||
<extracomment>The name of the StateType ({949b797d-5566-4667-8982-e430d23548e2}) of ThingClass sunspecThreePhaseInverter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="636"/>
|
||||
<source>Phase voltage</source>
|
||||
<extracomment>The name of the StateType ({4ca086e9-82b9-461c-b168-1d61b542b884}) of ThingClass sunspecSinglePhaseInverter</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="639"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="642"/>
|
||||
<source>Port</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="645"/>
|
||||
<source>Rated energy</source>
|
||||
<extracomment>The name of the StateType ({f6c6f548-8a12-4a62-a641-08bb003c55fa}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="648"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="651"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="654"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="657"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="660"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="663"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="666"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="669"/>
|
||||
<source>Serial number</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="672"/>
|
||||
<source>Set charging rate</source>
|
||||
<extracomment>The name of the ActionType ({7f469bbc-64a5-4045-8d5f-9a1a85039851}) of ThingClass sunspecStorage</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="675"/>
|
||||
<source>Set discharging rate</source>
|
||||
<extracomment>The name of the ActionType ({6068f030-acce-44a2-b95f-bd00dd5ca760}) of ThingClass sunspecStorage</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="678"/>
|
||||
<source>Set grid charging</source>
|
||||
<extracomment>The name of the ActionType ({221a2ef6-0a92-4ff0-87fe-7bd920dbec0b}) of ThingClass sunspecStorage</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="681"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="684"/>
|
||||
<source>Slave ID</source>
|
||||
<extracomment>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})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="687"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="690"/>
|
||||
<source>SolarEdge</source>
|
||||
<extracomment>The name of the ThingClass ({7a92bf65-b443-4491-a012-2bec35eb5bf0})
|
||||
----------
|
||||
The name of the vendor ({addd7dd8-13e1-405b-ba61-b2d82eaf0426})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="693"/>
|
||||
<source>SolarEdge battery</source>
|
||||
<extracomment>The name of the ThingClass ({7900471a-da3f-4f03-98be-3e3d9c841546})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="696"/>
|
||||
<source>State of health (SOH)</source>
|
||||
<extracomment>The name of the StateType ({24b501db-f301-422d-ad58-f16ca025f6ee}) of ThingClass solarEdgeBattery</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="699"/>
|
||||
<source>Status</source>
|
||||
<extracomment>The name of the StateType ({da2b19c5-0f48-49d1-93f0-abdc0051407d}) of ThingClass sunspecStorage</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="702"/>
|
||||
<source>SunSpec</source>
|
||||
<extracomment>The name of the vendor ({c143a7b4-a16c-4fff-86a3-9ffab3d6841d})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="705"/>
|
||||
<source>SunSpec Generic</source>
|
||||
<extracomment>The name of the ThingClass ({f51853f3-8815-4cf3-b337-45cda1f3e6d5})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="708"/>
|
||||
<source>SunSpec Storage</source>
|
||||
<extracomment>The name of the ThingClass ({9a643ba8-346c-4127-a2f8-956a7133d75e})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="711"/>
|
||||
<source>SunSpec single phase inverter</source>
|
||||
<extracomment>The name of the ThingClass ({c5d5204e-3375-4b92-8128-fab77a671fed})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="714"/>
|
||||
<source>SunSpec single phase meter</source>
|
||||
<extracomment>The name of the ThingClass ({7ffa43b8-b56f-4435-8509-980e9d81dfa8})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="717"/>
|
||||
<source>SunSpec split phase inverter</source>
|
||||
<extracomment>The name of the ThingClass ({61b38f93-d331-42bf-b1ef-d3fb16ad1230})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="720"/>
|
||||
<source>SunSpec split phase meter</source>
|
||||
<extracomment>The name of the ThingClass ({b8a18e45-5ff5-4f43-915f-04ee216c809d})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="723"/>
|
||||
<source>SunSpec three phase inverter</source>
|
||||
<extracomment>The name of the ThingClass ({2e4122ea-96a5-415c-b5e2-7d6012265a83})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="726"/>
|
||||
<source>SunSpec three phase meter</source>
|
||||
<extracomment>The name of the ThingClass ({68f822f9-ff30-4275-b229-39a3674fead7})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="729"/>
|
||||
<source>Timout</source>
|
||||
<extracomment>The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {1a8895a0-c746-48af-9307-3a4636f24cc2})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="732"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="735"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="738"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="741"/>
|
||||
<source>Total AC current</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="744"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="747"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="750"/>
|
||||
<source>Total real energy exported</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="753"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="756"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="759"/>
|
||||
<source>Total real energy imported</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="762"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="765"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="768"/>
|
||||
<source>Total real power</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="771"/>
|
||||
<source>Update interval</source>
|
||||
<extracomment>The name of the ParamType (ThingClass: sunSpec, Type: plugin, ID: {52da5222-9a94-47a2-9adc-004541d2f5ed})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="774"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="777"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="780"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="783"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="786"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="789"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="792"/>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="795"/>
|
||||
<source>Version</source>
|
||||
<extracomment>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</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../../../build-nymea-plugins-modbus-Desktop-Debug/sunspec/plugininfo.h" line="798"/>
|
||||
<source>sunspec</source>
|
||||
<extracomment>The name of the plugin SunSpec ({cb4bdec6-cf2c-4a0f-9709-42d951ca2d8b})</extracomment>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Loading…
x
Reference in New Issue
Block a user