-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
22 changed files
with
1,814 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
# Bosswerk | ||
|
||
This integration plugin allows to add Bosswerk Micro Inverters to nymea. | ||
|
||
## Supported devices | ||
|
||
* MI-300 | ||
* MI-600 | ||
|
||
## Requirements | ||
|
||
The solar inverter needs to be connected to the same network as nymea. Once powered on, the | ||
inverter will open a wireless hotspot named AP_XXXXXXXXXX where XXXXXXXXXX is the serial number | ||
written on the casing. The default password for this WiFi is 12345678. After connecting, | ||
open [http://10.10.100.254](http://10.10.100.254) with the browser and use the web interface | ||
on the inverter to connect it to a WiFi with nymea in it. | ||
|
||
Once done so, set up the inverter in nymea as any other thing. |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
|
||
requests: | ||
- start: 0x0001 | ||
end: 0x0070 | ||
mb_functioncode: 0x03 | ||
|
||
parameters: | ||
- group: solar | ||
items: | ||
- name: "PV1 Voltage" | ||
class: "voltage" | ||
state_class: "measurement" | ||
uom: "V" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x006D] | ||
icon: 'mdi:solar-power' | ||
|
||
- name: "PV2 Voltage" | ||
class: "voltage" | ||
state_class: "measurement" | ||
uom: "V" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x006F] | ||
icon: 'mdi:solar-power' | ||
|
||
- name: "PV1 Current" | ||
class: "current" | ||
uom: "A" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x006E] | ||
icon: 'mdi:solar-power' | ||
|
||
- name: "PV2 Current" | ||
class: "current" | ||
state_class: "measurement" | ||
uom: "A" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x0070] | ||
icon: 'mdi:solar-power' | ||
|
||
- name: "Daily Production" | ||
class: "energy" | ||
state_class: "total" | ||
uom: "kWh" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x003C] | ||
icon: 'mdi:solar-power' | ||
|
||
- name: "Total Production" | ||
class: "energy" | ||
state_class: "total_increasing" | ||
uom: "kWh" | ||
scale: 0.1 | ||
rule: 3 | ||
registers: [0x003F,0x0040] | ||
icon: 'mdi:solar-power' | ||
|
||
|
||
|
||
- group: Grid | ||
items: | ||
- name: "Grid Voltage L-L(A)" | ||
class: "voltage" | ||
state_class: "measurement" | ||
uom: "V" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x0049] | ||
icon: 'mdi:transmission-tower' | ||
|
||
- name: "Grid Voltage L-L(B))" | ||
class: "voltage" | ||
state_class: "measurement" | ||
uom: "V" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x004A] | ||
icon: 'mdi:transmission-tower' | ||
|
||
- name: "Grid Voltage L-L(C)" | ||
class: "voltage" | ||
state_class: "measurement" | ||
uom: "V" | ||
scale: 0.1 | ||
rule: 1 | ||
registers: [0x004B] | ||
icon: 'mdi:transmission-tower' | ||
|
||
- name: "Grid Current A" | ||
class: "current" | ||
state_class: "measurement" | ||
uom: "A" | ||
scale: 0.1 | ||
rule: 2 | ||
registers: [0x004C] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Grid Current B" | ||
class: "current" | ||
state_class: "measurement" | ||
uom: "A" | ||
scale: 0.1 | ||
rule: 2 | ||
registers: [0x004D] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Grid Current C" | ||
class: "current" | ||
state_class: "measurement" | ||
uom: "A" | ||
scale: 0.1 | ||
rule: 2 | ||
registers: [0x004E] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Grid Frequency" | ||
class: "current" | ||
state_class: "measurement" | ||
uom: "Hz" | ||
scale: 0.01 | ||
rule: 1 | ||
registers: [0x004F] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- group: Inverter | ||
items: | ||
- name: "Running Status" | ||
class: "" | ||
state_class: "" | ||
uom: "" | ||
scale: 1 | ||
rule: 1 | ||
registers: [0x003B] | ||
isstr: true | ||
lookup: | ||
- key: 0 | ||
value: "Stand-by" | ||
- key: 1 | ||
value: "Self-checking" | ||
- key: 2 | ||
value: "Normal" | ||
- key: 3 | ||
value: "FAULT" | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Total Ouput AC Power" | ||
class: "power" | ||
state_class: "measurement" | ||
uom: "W" | ||
scale: 0.1 | ||
rule: 3 | ||
registers: [0x0050,0x0051] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Input Active Power" | ||
class: "power" | ||
state_class: "measurement" | ||
uom: "W" | ||
scale: 0.1 | ||
rule: 3 | ||
registers: [0x0052, 0x0053] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Output Apparent Power" | ||
class: "apparent_power" | ||
state_class: "measurement" | ||
uom: "VA" | ||
scale: 0.1 | ||
rule: 3 | ||
registers: [0x0054, 0x0055] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Output Active Power" | ||
class: "energy" | ||
state_class: "measurement" | ||
uom: "kWh" | ||
scale: 0.1 | ||
rule: 3 | ||
registers: [0x0056, 0x0057] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "Output Reactive Power" | ||
class: "reactive_power" | ||
state_class: "measurement" | ||
uom: "VAR" | ||
rule: 3 | ||
scale: 0.1 | ||
registers: [0x0058, 0x0059] | ||
icon: 'mdi:home-lightning-bolt' | ||
|
||
- name: "temp" | ||
class: "temperature" | ||
state_class: "measurement" | ||
uom: "°C" | ||
scale: 0.01 | ||
rule: 1 | ||
offset: 1000 | ||
registers: [0x005A] | ||
icon: 'mdi:battery' | ||
|
||
- name: "Inverter ID" | ||
class: "" | ||
state_class: "" | ||
uom: "" | ||
scale: 1 | ||
rule: 5 | ||
registers: [0x0003,0x0004,0x0005,0x0006,0x0007] | ||
isstr: true |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import socket | ||
|
||
request = "WIFIKIT-214028-READ" | ||
address = ("<broadcast>", 48899) | ||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock: | ||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | ||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) | ||
sock.settimeout(1.0) | ||
|
||
sock.sendto(request.encode(), address) | ||
|
||
while True: | ||
try: | ||
data = sock.recv(1024) | ||
a = data.decode().split(',') | ||
if 3 == len(a): | ||
print("ip %s" % a[0]) | ||
print("mac %s" % a[1]) | ||
print("serial %s" % int(a[2])) | ||
except socket.timeout: | ||
break |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * | ||
* | ||
* Copyright 2013 - 2022, nymea GmbH | ||
* Contact: contact@nymea.io | ||
* | ||
* This file is part of nymea. | ||
* This project including source code and documentation is protected by | ||
* copyright law, and remains the property of nymea GmbH. All rights, including | ||
* reproduction, publication, editing and translation, are reserved. The use of | ||
* this project is subject to the terms of a license agreement to be concluded | ||
* with nymea GmbH in accordance with the terms of use of nymea GmbH, available | ||
* under https://nymea.io/license | ||
* | ||
* GNU Lesser General Public License Usage | ||
* Alternatively, this project may be redistributed and/or modified under the | ||
* terms of the GNU Lesser General Public License as published by the Free | ||
* Software Foundation; version 3. This project 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 | ||
* Lesser General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Lesser General Public License | ||
* along with this project. If not, see <https://www.gnu.org/licenses/>. | ||
* | ||
* For any further details and any questions please contact us under | ||
* contact@nymea.io or see our FAQ/Licensing Information on | ||
* https://nymea.io/license/faq | ||
* | ||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ | ||
|
||
|
||
#include "integrationpluginsolarman.h" | ||
#include "plugininfo.h" | ||
|
||
#include <plugintimer.h> | ||
#include <network/networkdevicediscovery.h> | ||
#include <network/networkaccessmanager.h> | ||
#include <network/networkdevicediscoveryreply.h> | ||
|
||
#include <QNetworkReply> | ||
#include <QAuthenticator> | ||
#include <QUrlQuery> | ||
#include <QJsonDocument> | ||
#include <QMetaEnum> | ||
#include <QUdpSocket> | ||
#include <QFile> | ||
#include <QDir> | ||
|
||
#include "solarmandiscovery.h" | ||
#include "solarmanmodbus.h" | ||
#include "solarmanmodbusreply.h" | ||
|
||
IntegrationPluginSolarman::IntegrationPluginSolarman() | ||
{ | ||
QFile registerMappings(":/registermappings.json"); | ||
registerMappings.open(QFile::ReadOnly); | ||
QJsonDocument jsonDoc = QJsonDocument::fromJson(registerMappings.readAll()); | ||
m_registerMappings = jsonDoc.toVariant().toMap(); | ||
} | ||
|
||
IntegrationPluginSolarman::~IntegrationPluginSolarman() | ||
{ | ||
} | ||
|
||
void IntegrationPluginSolarman::discoverThings(ThingDiscoveryInfo *info) | ||
{ | ||
SolarmanDiscovery *discovery = new SolarmanDiscovery(info); | ||
connect(discovery, &SolarmanDiscovery::discoveryResults, info, [this, info](const QList<SolarmanDiscovery::DiscoveryResult> &results) { | ||
foreach (const SolarmanDiscovery::DiscoveryResult &result, results) { | ||
ThingDescriptor descriptor(deyeStringThingClassId, "Deye String Inverter", "Serial: " + result.serial + ", IP: " + result.ip.toString()); | ||
descriptor.setParams({{deyeStringThingSerialParamTypeId, result.serial}}); | ||
|
||
if (myThings().findByParams(descriptor.params())) { | ||
descriptor.setThingId(myThings().findByParams(descriptor.params())->id()); | ||
} | ||
info->addThingDescriptor(descriptor); | ||
} | ||
info->finish(Thing::ThingErrorNoError); | ||
}); | ||
discovery->discover(); | ||
} | ||
|
||
void IntegrationPluginSolarman::setupThing(ThingSetupInfo *info) | ||
{ | ||
Thing *thing = info->thing(); | ||
|
||
SolarmanDiscovery *monitor = m_monitors.value(thing); | ||
if (!monitor) { | ||
monitor = new SolarmanDiscovery(thing); | ||
m_monitors.insert(thing, monitor); | ||
} | ||
|
||
if (!monitor->monitor(thing->paramValue(deyeStringThingSerialParamTypeId).toString())) { | ||
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Unalbe to open network connection.")); | ||
m_monitors.take(thing)->deleteLater(); | ||
return; | ||
} | ||
|
||
SolarmanModbus *modbus = new SolarmanModbus(thing); | ||
m_connections.insert(thing, modbus); | ||
|
||
// For testing | ||
// modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial); | ||
// pollDevice(thing); | ||
|
||
connect(monitor, &SolarmanDiscovery::monitorStateChanged, thing, [modbus, monitor](const SolarmanDiscovery::DiscoveryResult &result) { | ||
if (result.online && !modbus->isConnected()) { | ||
modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial); | ||
} | ||
}); | ||
|
||
connect(modbus, &SolarmanModbus::connectedChanged, thing, [this, thing, monitor, modbus](bool connected){ | ||
thing->setStateValue(deyeStringConnectedStateTypeId, connected); | ||
if (connected) { | ||
pollDevice(thing); | ||
m_timers.value(thing)->reset(); | ||
m_timers.value(thing)->start(); | ||
} else { | ||
m_timers.value(thing)->stop(); | ||
if (monitor->monitorResult().online) { | ||
qCDebug(dcSolarman()) << "Reconnecting to solarman inverter"; | ||
modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial); | ||
} | ||
} | ||
}); | ||
|
||
PluginTimer *timer = m_timers.take(thing); | ||
if (!timer) { | ||
// the inverter updates its internal values once per minute... | ||
timer = hardwareManager()->pluginTimerManager()->registerTimer(21); | ||
m_timers.insert(thing, timer); | ||
|
||
connect(timer, &PluginTimer::timeout, thing, [this, thing](){ | ||
pollDevice(thing); | ||
}); | ||
} | ||
|
||
info->finish(Thing::ThingErrorNoError); | ||
} | ||
|
||
void IntegrationPluginSolarman::thingRemoved(Thing *thing) | ||
{ | ||
m_monitors.remove(thing); | ||
m_connections.remove(thing); | ||
hardwareManager()->pluginTimerManager()->unregisterTimer(m_timers.take(thing)); | ||
} | ||
|
||
void IntegrationPluginSolarman::pollDevice(Thing *thing) | ||
{ | ||
SolarmanModbus *modbus = m_connections.value(thing); | ||
if (!modbus->isConnected()) { | ||
qCDebug(dcSolarman()) << "Inverter offline. Not polling."; | ||
return; | ||
} | ||
|
||
QVariantMap mapping = m_registerMappings.value(thing->thingClass().name()).toMap(); | ||
quint8 slaveId = mapping.value("slaveId").toUInt(); | ||
quint16 startRegister = mapping.value("startRegister").toUInt(); | ||
quint16 endRegister = mapping.value("endRegister").toUInt(); | ||
QMetaEnum fcEnum = QMetaEnum::fromType<SolarmanModbus::FunctionCode>(); | ||
SolarmanModbus::FunctionCode functionCode = static_cast<SolarmanModbus::FunctionCode>(fcEnum.keyToValue(mapping.value("functionCode").toByteArray())); | ||
QVariantList registers = mapping.value("registers").toList(); | ||
|
||
|
||
SolarmanModbusReply *reply = modbus->readRegisters(slaveId, startRegister, endRegister, functionCode); | ||
qCDebug(dcSolarman()) << "Polling inverter" << thing->name() << "slaveID:" << slaveId << "Start:" << startRegister << "End:" << endRegister << "Request ID" << reply->requestId(); | ||
connect(reply, &SolarmanModbusReply::finished, thing, [modbus, thing, reply, registers](bool success){ | ||
if (!success) { | ||
qCWarning(dcSolarman()) << "Polling failed..." << reply->requestId(); | ||
modbus->disconnectFromHost(); | ||
return; | ||
} | ||
qCDebug(dcSolarman()) << "modbus reply for" << thing->name() << reply->requestId(); | ||
|
||
foreach (const QVariant ®isterVariant, registers) { | ||
QVariantMap definition = registerVariant.toMap(); | ||
quint16 reg = definition.value("index").toUInt(); | ||
quint8 length = definition.value("length").toUInt(); | ||
QString type = definition.value("type").toString(); | ||
QString stateName = definition.value("state").toString(); | ||
QString registerName = definition.value("name").toString(); | ||
int offset = definition.value("offset", 0).toInt(); | ||
double scale = definition.value("scale", 1).toDouble(); | ||
|
||
|
||
QVariant value; | ||
if (type == "uint16") { | ||
double val = reply->readRegister16(reg); | ||
val += offset; | ||
val *= scale; | ||
value = val; | ||
|
||
} else if (type == "uint32") { | ||
double val = reply->readRegister32(reg); | ||
val += offset; | ||
val *= scale; | ||
value = val; | ||
} else if (type == "string") { | ||
value = reply->readRegisterString(reg, length); | ||
} | ||
qCDebug(dcSolarman()) << "Register" << reg << registerName << ":" << value; | ||
if (!stateName.isEmpty()) { | ||
thing->setStateValue(stateName, value); | ||
} | ||
} | ||
|
||
|
||
// qCDebug(dcSolarman()) << "Today's production:" << reply->readRegister16(0x003C); | ||
// qCDebug(dcSolarman()) << "Inverter status:" << reply->readRegister16(0x003B); | ||
// qCDebug(dcSolarman()) << "Total production:" << reply->readRegister32(0x003F); | ||
}); | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * | ||
* | ||
* Copyright 2013 - 2022, nymea GmbH | ||
* Contact: contact@nymea.io | ||
* | ||
* This file is part of nymea. | ||
* This project including source code and documentation is protected by | ||
* copyright law, and remains the property of nymea GmbH. All rights, including | ||
* reproduction, publication, editing and translation, are reserved. The use of | ||
* this project is subject to the terms of a license agreement to be concluded | ||
* with nymea GmbH in accordance with the terms of use of nymea GmbH, available | ||
* under https://nymea.io/license | ||
* | ||
* GNU Lesser General Public License Usage | ||
* Alternatively, this project may be redistributed and/or modified under the | ||
* terms of the GNU Lesser General Public License as published by the Free | ||
* Software Foundation; version 3. This project 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 | ||
* Lesser General Public License for more details. | ||
* | ||
* You should have received a copy of the GNU Lesser General Public License | ||
* along with this project. If not, see <https://www.gnu.org/licenses/>. | ||
* | ||
* For any further details and any questions please contact us under | ||
* contact@nymea.io or see our FAQ/Licensing Information on | ||
* https://nymea.io/license/faq | ||
* | ||
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ | ||
|
||
#ifndef INTEGRATIONPLUGINSOLARMAN_H | ||
#define INTEGRATIONPLUGINSOLARMAN_H | ||
|
||
#include "integrations/integrationplugin.h" | ||
#include "extern-plugininfo.h" | ||
|
||
class PluginTimer; | ||
class SolarmanDiscovery; | ||
class SolarmanModbus; | ||
|
||
class IntegrationPluginSolarman: public IntegrationPlugin | ||
{ | ||
Q_OBJECT | ||
|
||
Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginsolarman.json") | ||
Q_INTERFACES(IntegrationPlugin) | ||
|
||
public: | ||
enum Method { | ||
GET, | ||
SET | ||
}; | ||
Q_ENUM(Method) | ||
|
||
explicit IntegrationPluginSolarman(); | ||
~IntegrationPluginSolarman(); | ||
|
||
void discoverThings(ThingDiscoveryInfo *info) override; | ||
void setupThing(ThingSetupInfo *info) override; | ||
void thingRemoved(Thing *thing) override; | ||
|
||
private slots: | ||
void pollDevice(Thing *thing); | ||
|
||
private: | ||
QHash<Thing*, SolarmanDiscovery*> m_monitors; | ||
QHash<Thing*, SolarmanModbus*> m_connections; | ||
QHash<Thing*, PluginTimer*> m_timers; | ||
|
||
QVariantMap m_registerMappings; | ||
}; | ||
|
||
#endif // INTEGRATIONPLUGINSOLARMAN_H |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
{ | ||
"name": "solarman", | ||
"displayName": "Solarman", | ||
"id": "9a37f9db-4b82-4bbc-b1ab-7eed5a1f4b70", | ||
"vendors": [ | ||
{ | ||
"name": "solarman", | ||
"displayName": "Solarman", | ||
"id": "d6bc0ecd-8cbe-4d5d-a606-0712c5f10978", | ||
"thingClasses": [ | ||
{ | ||
"id": "9712b0f0-45a3-4c27-a675-4ef6a0f1dc1e", | ||
"name": "deyeString", | ||
"displayName": "Deye String Inverter", | ||
"createMethods": ["discovery"], | ||
"interfaces": [ "solarinverter", "wirelessconnectable" ], | ||
"paramTypes": [ | ||
{ | ||
"id": "e23e8186-e3df-4dc1-87cf-59627e1d2a3d", | ||
"name":"serial", | ||
"displayName": "Serial number", | ||
"type": "QString" | ||
} | ||
], | ||
"stateTypes": [ | ||
{ | ||
"id": "7b110485-7b12-425b-b4c2-6a0a76db69cf", | ||
"name": "connected", | ||
"displayName": "Connected", | ||
"displayNameEvent": "Connected changed", | ||
"type": "bool", | ||
"defaultValue": false, | ||
"cached": false | ||
}, | ||
{ | ||
"id": "1303b3fa-072f-468e-949c-2e1474ce8f1e", | ||
"name": "signalStrength", | ||
"displayName": "Signal strength", | ||
"displayNameEvent": "Signal strength changed", | ||
"type": "uint", | ||
"unit": "Percentage", | ||
"minValue": 0, | ||
"maxValue": 100, | ||
"defaultValue": 0, | ||
"filter": "adaptive", | ||
"cached": false | ||
}, | ||
{ | ||
"id": "49e29f3b-d04b-4972-94e1-d5485405f716", | ||
"name": "currentPower", | ||
"displayName": "Current power consumption", | ||
"displayNameEvent": "Current power consumption changed", | ||
"type": "double", | ||
"unit": "Watt", | ||
"defaultValue": 0, | ||
"cached": false | ||
}, | ||
{ | ||
"id": "0118a424-6f93-4f37-a9da-17617b508a45", | ||
"name": "totalEnergyProduced", | ||
"displayName": "Total produced energy", | ||
"displayNameEvent": "Total produced energy changed", | ||
"type": "double", | ||
"unit": "KiloWattHour", | ||
"defaultValue": 0 | ||
}, | ||
{ | ||
"id": "bc8ed7f6-6139-43e4-8d49-32ec5020d493", | ||
"name": "temperature", | ||
"displayName": "Inverter temperature", | ||
"displayNameEvent": "Inverter temperature changed", | ||
"type": "double", | ||
"unit": "DegreeCelsius", | ||
"defaultValue": 0 | ||
} | ||
|
||
] | ||
} | ||
] | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
{ | ||
"title": "Bosswerk", | ||
"tagline": "Integrates Bosswerk solar inverters with nymea.", | ||
"icon": "bosswerk.jpg", | ||
"stability": "consumer", | ||
"offline": true, | ||
"technologies": [ | ||
"network" | ||
], | ||
"categories": [ | ||
"energy" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
WIFIKIT-214028-READ 10.10.10.255 48899 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
{ | ||
"deyeString": { | ||
"slaveId": 1, | ||
"startRegister": 1, | ||
"endRegister": 116, | ||
"functionCode": "ReadHoldingRegisters", | ||
"registers": [ | ||
{ | ||
"index": 3, | ||
"type": "string", | ||
"length": 5, | ||
"name": "Inverter serial" | ||
}, | ||
{ | ||
"index": 16, | ||
"type": "uint16", | ||
"name": "Rated power", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 59, | ||
"type": "uint16", | ||
"name": "Inverter status" | ||
}, | ||
{ | ||
"index": 60, | ||
"type": "uint16", | ||
"name": "Daily production (Active)", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 63, | ||
"type": "uint32", | ||
"name": "Total Production (Active)", | ||
"state": "totalEnergyProduced", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 73, | ||
"type": "uint16", | ||
"name": "AC Voltage 1", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 74, | ||
"type": "uint16", | ||
"name": "AC Voltage 2", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 75, | ||
"type": "uint16", | ||
"name": "AC Voltage 3", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 76, | ||
"type": "uint16", | ||
"name": "AC Current 1", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 77, | ||
"type": "uint16", | ||
"name": "AC Current 2", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 78, | ||
"type": "uint16", | ||
"name": "AC Current 3", | ||
"scale": 0.1 | ||
}, | ||
{ | ||
"index": 79, | ||
"type": "uint16", | ||
"name": "AC Output Frequency", | ||
"scale": 0.01 | ||
}, | ||
{ | ||
"index": 86, | ||
"name": "Total AC Output Power (Active)", | ||
"type": "uint32", | ||
"state": "currentPower", | ||
"scale": -0.1 | ||
}, | ||
{ | ||
"index": 90, | ||
"name": "Temerpature", | ||
"type": "uint16", | ||
"state": "temperature", | ||
"offset": -1000, | ||
"scale": 0.01 | ||
} | ||
] | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<RCC> | ||
<qresource prefix="/"> | ||
<file>registermappings.json</file> | ||
</qresource> | ||
</RCC> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
include(../plugins.pri) | ||
|
||
QT += network | ||
|
||
SOURCES += \ | ||
integrationpluginsolarman.cpp \ | ||
solarmandiscovery.cpp \ | ||
solarmanmodbus.cpp \ | ||
solarmanmodbusreply.cpp | ||
|
||
HEADERS += \ | ||
integrationpluginsolarman.h \ | ||
solarmandiscovery.h \ | ||
solarmanmodbus.h \ | ||
solarmanmodbusreply.h | ||
|
||
DISTFILES += \ | ||
registermappings.json | ||
|
||
RESOURCES += \ | ||
registermappings.qrc |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
#include "solarmandiscovery.h" | ||
|
||
#include <QTimer> | ||
#include <QUdpSocket> | ||
|
||
#include <QLoggingCategory> | ||
Q_DECLARE_LOGGING_CATEGORY(dcSolarman) | ||
|
||
SolarmanDiscovery::SolarmanDiscovery(QObject *parent) | ||
: QObject{parent} | ||
{ | ||
} | ||
|
||
bool SolarmanDiscovery::discover() | ||
{ | ||
if (!initUdp()) { | ||
return false; | ||
} | ||
|
||
if (m_discoveryTimer) { | ||
qCDebug(dcSolarman()) << "Already discovering..."; | ||
return true; | ||
} | ||
|
||
m_discoveryTimer = new QTimer(this); | ||
m_discoveryTimer->start(1000); | ||
|
||
connect(m_discoveryTimer, &QTimer::timeout, this, [=](){ | ||
int counter = m_discoveryTimer->property("counter").toInt(); | ||
if (counter < 5) { | ||
m_discoveryTimer->setProperty("counter", counter+1); | ||
sendDiscoveryMessage(); | ||
} else { | ||
emit discoveryResults(m_discoveryResults); | ||
m_discoveryResults.clear(); | ||
delete m_discoveryTimer; | ||
m_discoveryTimer = nullptr; | ||
} | ||
}); | ||
|
||
bool status = sendDiscoveryMessage(); | ||
if (!status) { | ||
delete m_discoveryTimer; | ||
m_discoveryTimer = nullptr; | ||
} | ||
return status; | ||
} | ||
|
||
bool SolarmanDiscovery::monitor(const QString &serial) | ||
{ | ||
if (!initUdp()) { | ||
return false; | ||
} | ||
|
||
m_monitoringResult.serial = serial; | ||
|
||
if (!m_monitorTimer) { | ||
m_monitorTimer = new QTimer(this); | ||
m_monitorTimer->start(15000); | ||
connect(m_monitorTimer, &QTimer::timeout, this, [=](){ | ||
sendDiscoveryMessage(); | ||
|
||
if (m_monitoringResult.lastSeen.msecsTo(QDateTime::currentDateTime()) > 60000) { | ||
m_monitoringResult.online = false; | ||
emit monitorStateChanged(m_monitoringResult); | ||
} | ||
}); | ||
} | ||
|
||
bool status = sendDiscoveryMessage(); | ||
if (!status) { | ||
delete m_monitorTimer; | ||
m_monitorTimer = nullptr; | ||
} | ||
return status; | ||
} | ||
|
||
SolarmanDiscovery::DiscoveryResult SolarmanDiscovery::monitorResult() const | ||
{ | ||
return m_monitoringResult; | ||
} | ||
|
||
void SolarmanDiscovery::onReadyRead() | ||
{ | ||
while (m_udp->hasPendingDatagrams()) { | ||
QByteArray data; | ||
data.resize(m_udp->pendingDatagramSize()); | ||
QHostAddress host; | ||
m_udp->readDatagram(data.data(), m_udp->pendingDatagramSize(), &host); | ||
if (data == "WIFIKIT-214028-READ") { | ||
continue; | ||
} | ||
qCDebug(dcSolarman) << "UDP datagram from" << host.toString() << data; | ||
|
||
QStringList parts = QString(data).split(","); | ||
if (parts.count() != 3) { | ||
qCDebug(dcSolarman()) << "Unexpected discovery reply format:" << data; | ||
continue; | ||
} | ||
DiscoveryResult result; | ||
result.ip = parts.at(0); | ||
result.mac = parts.at(1); | ||
result.serial = parts.at(2); | ||
result.online = true; | ||
|
||
// Sanity check if the claimed IP matches with the sender | ||
if (result.ip.toIPv4Address() != host.toIPv4Address()) { | ||
qCDebug(dcSolarman) << "Sender IP doesn't match claimed IP:" << result.ip << host; | ||
continue; | ||
} | ||
|
||
if (m_discoveryTimer) { | ||
qCDebug(dcSolarman()) << "Found solarman device" << result.serial << "on" << result.ip; | ||
if (!m_discoveryResults.contains(result)) { | ||
m_discoveryResults.append(result); | ||
} | ||
} | ||
|
||
if (m_monitorTimer) { | ||
if (result.serial == m_monitoringResult.serial) { | ||
m_monitoringResult = result; | ||
m_monitoringResult.lastSeen = QDateTime::currentDateTime(); | ||
emit monitorStateChanged(result); | ||
} | ||
} | ||
} | ||
} | ||
|
||
bool SolarmanDiscovery::initUdp() | ||
{ | ||
if (m_udp) { | ||
return true; | ||
} | ||
|
||
m_udp = new QUdpSocket(this); | ||
connect(m_udp, &QUdpSocket::readyRead, this, &SolarmanDiscovery::onReadyRead); | ||
bool status = m_udp->bind(48899, QUdpSocket::ShareAddress); | ||
if (!status) { | ||
qCWarning(dcSolarman()) << "Unable to bind UDP port 48899. SolarmanDiscovery won't work."; | ||
delete m_udp; | ||
m_udp = nullptr; | ||
} | ||
return status; | ||
} | ||
|
||
bool SolarmanDiscovery::sendDiscoveryMessage() | ||
{ | ||
qCDebug(dcSolarman()) << "Discovering solarman inverters..."; | ||
QByteArray discoveryString("WIFIKIT-214028-READ"); | ||
int len = m_udp->writeDatagram(discoveryString, QHostAddress::Broadcast, 48899); | ||
return len == discoveryString.length(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
#ifndef SOLARMANDISCOVERY_H | ||
#define SOLARMANDISCOVERY_H | ||
|
||
#include <QObject> | ||
#include <QHostAddress> | ||
#include <QTimer> | ||
#include <QUdpSocket> | ||
#include <QDateTime> | ||
|
||
class SolarmanDiscovery : public QObject | ||
{ | ||
Q_OBJECT | ||
public: | ||
class DiscoveryResult { | ||
public: | ||
QString serial; | ||
QHostAddress ip; | ||
QString mac; | ||
bool online; | ||
QDateTime lastSeen; | ||
bool operator==(const DiscoveryResult &other) { | ||
return serial == other.serial; | ||
} | ||
}; | ||
|
||
explicit SolarmanDiscovery(QObject *parent = nullptr); | ||
|
||
bool discover(); | ||
|
||
bool monitor(const QString &serial); | ||
DiscoveryResult monitorResult() const; | ||
|
||
signals: | ||
void discoveryResults(const QList<DiscoveryResult> &results); | ||
void monitorStateChanged(const DiscoveryResult &result); | ||
|
||
private slots: | ||
void onReadyRead(); | ||
|
||
private: | ||
bool initUdp(); | ||
bool sendDiscoveryMessage(); | ||
|
||
QList<DiscoveryResult> m_discoveryResults; | ||
|
||
QUdpSocket *m_udp = nullptr;; | ||
DiscoveryResult m_monitoringResult; | ||
QTimer *m_discoveryTimer = nullptr; | ||
QTimer *m_monitorTimer = nullptr; | ||
}; | ||
|
||
#endif // SOLARMANDISCOVERY_H |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,305 @@ | ||
#include "solarmanmodbus.h" | ||
#include "solarmanmodbusreply.h" | ||
|
||
#include <QDataStream> | ||
#include <QHostAddress> | ||
#include <QTimer> | ||
#include <QLoggingCategory> | ||
#include <QDateTime> | ||
#include <QTimeZone> | ||
|
||
|
||
Q_DECLARE_LOGGING_CATEGORY(dcSolarman) | ||
|
||
#define MOCK_DATA 0 | ||
|
||
quint8 START_OF_MESSAGE = 0xA5; | ||
quint8 END_OF_MESSAGE = 0x15; | ||
quint8 FRAME_TYPE = 0x02; | ||
quint16 requestCode = 0x4510; | ||
quint16 responseCode = 0x1510; | ||
|
||
SolarmanModbus::SolarmanModbus(QObject *parent) | ||
: QObject{parent} | ||
{ | ||
m_socket = new QTcpSocket(this); | ||
connect(m_socket, &QTcpSocket::stateChanged, this, [=](QAbstractSocket::SocketState state){ | ||
qCDebug(dcSolarman()) << "Socket state changed:" << state; | ||
if (state == QAbstractSocket::ConnectedState) { | ||
emit connectedChanged(true); | ||
} else if (state == QAbstractSocket::UnconnectedState){ | ||
emit connectedChanged(false); | ||
} | ||
}, Qt::QueuedConnection); // Otherwise socket->isOpen() may stll be true if the user reconnects right away. | ||
|
||
connect(m_socket, &QTcpSocket::readyRead, this, [this](){ | ||
QByteArray data = m_socket->readAll(); | ||
processData(data); | ||
}); | ||
} | ||
|
||
void SolarmanModbus::connectToHost(const QHostAddress &host, quint16 port, const QString &serial) | ||
{ | ||
if (m_serial != serial || m_host != host || m_port != port) { | ||
m_serial = serial; | ||
m_host = host; | ||
m_port = port; | ||
m_socket->close(); | ||
} | ||
|
||
if (!m_socket->isOpen()) { | ||
qCDebug(dcSolarman()) << "Connecting to" << host.toString() << port; | ||
m_socket->connectToHost(host, port); | ||
} else { | ||
qCDebug(dcSolarman()) << "Still connected. Not reconnecting"; | ||
} | ||
} | ||
|
||
void SolarmanModbus::disconnectFromHost() | ||
{ | ||
m_serial.clear(); | ||
m_socket->disconnectFromHost(); | ||
emit connectedChanged(false); | ||
} | ||
|
||
bool SolarmanModbus::isConnected() const | ||
{ | ||
return m_socket->isOpen(); | ||
} | ||
|
||
SolarmanModbusReply *SolarmanModbus::readRegisters(int slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode) | ||
{ | ||
|
||
#if MOCK_DATA == 1 | ||
quint8 requestId = 0; | ||
SolarmanModbusReply *reply = new SolarmanModbusReply(requestId, slaveId, startRegister, endRegister, functionCode, this); | ||
|
||
QByteArray mockData = QByteArray::fromHex( | ||
// Late afternoon | ||
"a5f30010150033fdc5fff40201aaab35004b0600001e2dba620103e0010002013232303230393036353500010000120c070000000112020700000bb800000101004b0000003c160807111210000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004001100000000042a00000011000000000000042a0000000000000938000000000000000000001388000000000000000000000000042e00000000000014d20000000000000000000000000000000000000000000000000000000000000000000000000174001c0000000000356b15" | ||
// End of day | ||
// "a5f3001015002bfdc5fff402011a2900005a0600006adcef620103e0010002013232303230393036353500010000120c070000000112020700000bb800000101004b0000003c160807141d08000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004001300000000042c00000013000000000000042c0000000000000924000000000000000000001388000000000000000000000000001e000000000000106800000000000000000000000000000000000000000000000000000000000000000000000001460000000000008ce6c615" | ||
); | ||
m_pendingReplies.append(reply); | ||
QMetaObject::invokeMethod(this, "processData", Qt::QueuedConnection, Q_ARG(QByteArray, mockData)); | ||
#else | ||
|
||
quint8 requestId = m_requestIdCounter++; | ||
SolarmanModbusReply *reply = new SolarmanModbusReply(requestId, slaveId, startRegister, endRegister, functionCode, this); | ||
|
||
if (!m_socket->isOpen()) { | ||
reply->finish(false); | ||
return reply; | ||
} | ||
m_pendingReplies.append(reply); | ||
connect(reply, &SolarmanModbusReply::finished, this, [this, reply](){ | ||
m_pendingReplies.removeAll(reply); | ||
}); | ||
QByteArray request = createRequest(requestId, slaveId, startRegister, endRegister, functionCode, m_serial); | ||
qCDebug(dcSolarman) << "Requesting:" << request.toHex(); | ||
m_socket->write(request); | ||
#endif | ||
|
||
return reply; | ||
} | ||
|
||
void SolarmanModbus::processData(const QByteArray &data) | ||
{ | ||
qCDebug(dcSolarman) << "Data received:" << data.toHex(); | ||
|
||
if (!validateChecksum(data)) { | ||
qCWarning(dcSolarman()) << "Checksum verification failed for payload:" << data.toHex(); | ||
return; | ||
} | ||
|
||
QDataStream stream(data); | ||
stream.setByteOrder(QDataStream::LittleEndian); | ||
quint8 startOfMessage; | ||
stream >> startOfMessage; | ||
if (startOfMessage != START_OF_MESSAGE) { | ||
qCWarning(dcSolarman()) << "Skipping message... No Start of message found."; | ||
return; | ||
} | ||
quint16 payloadLength; | ||
stream >> payloadLength; | ||
qCDebug(dcSolarman()) << "Payload length:" << payloadLength; | ||
quint16 commandCode; | ||
stream >> commandCode; | ||
|
||
quint8 requestId; | ||
stream >> requestId; | ||
quint8 packetCounter; | ||
stream >> packetCounter; // Probably for deduplication, dunno, just counts up to 0x6a (100) and resets to 1... | ||
|
||
|
||
if (commandCode != responseCode) { | ||
qCWarning(dcSolarman()) << "Unknown packet received:" << data.toHex(); | ||
disconnectFromHost(); | ||
return; | ||
QByteArray rsp; | ||
QDataStream rs(&rsp, QIODevice::WriteOnly); | ||
stream.setByteOrder(QDataStream::LittleEndian); | ||
rs << static_cast<quint8>(START_OF_MESSAGE); | ||
rs << static_cast<quint16>(1); // len | ||
rs << static_cast<quint16>(0x1710); | ||
rs << requestId; | ||
rs << packetCounter; | ||
QByteArray serialHex = getSerialHex(m_serial); | ||
qDebug() << "Serial hex:" << serialHex.toHex(); | ||
rs.writeRawData(serialHex.data(), serialHex.length()); | ||
rs << static_cast<quint8>(0); | ||
rs << createChecksum(rsp); | ||
rs << static_cast<quint8>(END_OF_MESSAGE); | ||
m_socket->write(rsp); | ||
return; | ||
} | ||
|
||
|
||
char serialStr[4]; | ||
stream.readRawData(serialStr, 4); | ||
QByteArray serial(serialStr, 4); | ||
|
||
if (serial != getSerialHex(m_serial)) { | ||
qCWarning(dcSolarman()) << "Serial number does not match:" << serial.toHex() << getSerialHex(m_serial).toHex(); | ||
return; | ||
} | ||
|
||
// Skipping 10 unknown characters | ||
quint8 frameType; | ||
stream >> frameType; | ||
|
||
quint8 sensorType; | ||
stream >> sensorType; | ||
|
||
quint32 deliveryTime; | ||
stream >> deliveryTime; | ||
|
||
quint32 powerOnTime; | ||
stream >> powerOnTime; | ||
// char unknownStr[10]; | ||
// stream.readRawData(unknownStr, 10); | ||
|
||
quint32 timestamp; | ||
stream >> timestamp; | ||
qCDebug(dcSolarman()) << "Device time:" << QDateTime::fromMSecsSinceEpoch((qulonglong)timestamp * 1000, QTimeZone::utc()); | ||
|
||
quint8 slaveId; | ||
stream >> slaveId; | ||
|
||
quint8 rt; | ||
stream >> rt; | ||
FunctionCode functionCode = static_cast<FunctionCode>(rt); | ||
|
||
quint8 registersLength; | ||
stream >> registersLength; | ||
|
||
char dataStr[registersLength]; | ||
stream.readRawData(dataStr, registersLength); | ||
QByteArray registers(dataStr, registersLength); | ||
|
||
quint16 modbusCRC; | ||
stream >> modbusCRC; | ||
// While the checksum on the outer package is quite crappy (I've seen it colliding easily), we still don't bother checking the modbus CRC if that matches... | ||
|
||
qCDebug(dcSolarman()) << "Reply received: SlaveID" << slaveId << functionCode << "payload len" << registersLength << "poweruptime" << powerOnTime << "deliverytime" << deliveryTime << "frametype" << frameType; | ||
|
||
foreach (SolarmanModbusReply *reply, m_pendingReplies) { | ||
if (reply->requestId() == requestId) { | ||
reply->finish(true, registers); | ||
} | ||
} | ||
} | ||
|
||
QByteArray SolarmanModbus::createRequest(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode, const QString &serialNumber) { | ||
|
||
|
||
QByteArray packet; | ||
QDataStream stream(&packet, QIODevice::WriteOnly); | ||
stream.setByteOrder(QDataStream::LittleEndian); | ||
stream << START_OF_MESSAGE; | ||
QByteArray modbusPayload = createModbusPayload(slaveId, startRegister, endRegister, functionCode); | ||
stream << static_cast<quint16>(15 + modbusPayload.length()); | ||
// stream.writeRawData((char*)CONTROL_CODE, 2); | ||
stream << requestCode; | ||
stream << requestId; | ||
stream << requestId;//static_cast<quint8>(0x00); // This seems to be a static counter counting up unrelated on both ends, but the inverter doesn't care if we don't use it. | ||
QByteArray serialHex = getSerialHex(serialNumber); | ||
qDebug() << "Serial hex:" << serialHex.toHex(); | ||
stream.writeRawData(serialHex.data(), serialHex.length()); | ||
stream << FRAME_TYPE; | ||
stream << static_cast<quint16>(0x0000); // Sensor type | ||
stream << static_cast<quint32>(0x00000000); // Deliverytime | ||
stream << static_cast<quint32>(0x00000000); // PowerOnTime | ||
stream << static_cast<quint32>(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch() / 1000); | ||
stream.writeRawData(modbusPayload.data(), modbusPayload.length()); | ||
stream << createChecksum(packet); | ||
stream << END_OF_MESSAGE; | ||
return packet; | ||
} | ||
|
||
QByteArray SolarmanModbus::createModbusPayload(quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode) | ||
{ | ||
quint16 registerCount = endRegister - startRegister + 1; | ||
|
||
QByteArray data; | ||
QDataStream stream(&data, QIODevice::WriteOnly); | ||
stream << static_cast<quint8>(slaveId); | ||
stream << static_cast<quint8>(functionCode); | ||
stream << startRegister; | ||
stream << registerCount; | ||
quint16 crc = createModbusCRC(data); | ||
stream.setByteOrder(QDataStream::LittleEndian); | ||
// stream.setByteOrder(QDataStream::BigEndian); | ||
stream << crc; | ||
return data; | ||
} | ||
|
||
quint16 SolarmanModbus::createModbusCRC(const QByteArray &data) | ||
{ | ||
quint16 poly = 0xA001; | ||
QDataStream stream(data); | ||
quint16 crc = 0xFFFF; | ||
|
||
while (!stream.atEnd()) { | ||
quint8 byte; | ||
stream >> byte; | ||
crc ^= byte; | ||
for (int i = 0; i < 8; i++) { | ||
if (crc & 0x0001) { | ||
crc = (crc >> 1) ^ poly; | ||
} else { | ||
crc = crc >> 1; | ||
} | ||
} | ||
} | ||
return crc; | ||
} | ||
|
||
QByteArray SolarmanModbus::getSerialHex(const QString &serialNumber) { | ||
QByteArray serialHex = QByteArray::fromHex(QByteArray::number(serialNumber.toLongLong(), 16)); | ||
QByteArray reversed; | ||
reversed.reserve(serialHex.size()); | ||
for (int i = serialHex.size() - 1; i >= 0; i--) { | ||
reversed.append(serialHex.at(i)); | ||
} | ||
return reversed; | ||
} | ||
|
||
quint8 SolarmanModbus::createChecksum(const QByteArray &data) | ||
{ | ||
quint16 checksum = 0; | ||
for (int i = 1; i < data.length(); i++) { | ||
checksum += (quint8)data.at(i); | ||
} | ||
return checksum; | ||
} | ||
|
||
bool SolarmanModbus::validateChecksum(const QByteArray &packet) { | ||
quint16 checksum = 0; | ||
// Don't include the checksum and END OF MESSAGE (-2) | ||
for (int i = 1; i < packet.length() - 2; i++) { | ||
checksum += packet[i]; | ||
} | ||
quint8 final = static_cast<quint8>(checksum); | ||
return final == (quint8)packet.at(packet.length() - 2); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
#ifndef SOLARMANMODBUS_H | ||
#define SOLARMANMODBUS_H | ||
|
||
#include <QObject> | ||
#include <QHostAddress> | ||
#include <QTcpSocket> | ||
|
||
class SolarmanModbusReply; | ||
|
||
class SolarmanModbus : public QObject | ||
{ | ||
Q_OBJECT | ||
public: | ||
enum FunctionCode { | ||
Invalid = 0x00, | ||
ReadCoilStatus = 0x01, | ||
ReadInputStatus = 0x02, | ||
ReadHoldingRegisters = 0x03, | ||
ReadInputRegisters = 0x04, | ||
ForceSingleCoil = 0x05, | ||
ForceSingleRegister = 0x06, | ||
ForceMultipleCoils = 0x0F, | ||
PresetMultipleRegisters = 0x10 | ||
}; | ||
Q_ENUM(FunctionCode) | ||
|
||
explicit SolarmanModbus(QObject *parent = nullptr); | ||
|
||
void connectToHost(const QHostAddress &host, quint16 port, const QString &serial); | ||
void disconnectFromHost(); | ||
bool isConnected() const; | ||
|
||
SolarmanModbusReply* readRegisters(int slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode); | ||
|
||
signals: | ||
void connectedChanged(bool connected); | ||
|
||
private slots: | ||
void processData(const QByteArray &data); | ||
|
||
private: | ||
QByteArray createRequest(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode, const QString &serialNumber); | ||
QByteArray createModbusPayload(quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode); | ||
quint16 createModbusCRC(const QByteArray &data); | ||
QByteArray getSerialHex(const QString &serialNumber); | ||
|
||
quint8 createChecksum(const QByteArray &data); | ||
bool validateChecksum(const QByteArray &data); | ||
|
||
QString m_serial; | ||
QHostAddress m_host; | ||
quint16 m_port = 0; | ||
QTcpSocket *m_socket = nullptr; | ||
quint8 m_requestIdCounter = 0; | ||
|
||
QList<SolarmanModbusReply*> m_pendingReplies; | ||
}; | ||
|
||
#endif // SOLARMANMODBUS_H |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
#include "solarmanmodbusreply.h" | ||
|
||
#include <QTimer> | ||
#include <QDataStream> | ||
#include <QLoggingCategory> | ||
|
||
Q_DECLARE_LOGGING_CATEGORY(dcSolarman) | ||
|
||
SolarmanModbusReply::SolarmanModbusReply(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, SolarmanModbus::FunctionCode functionCode, QObject *parent) | ||
: QObject{parent}, | ||
m_requestId(requestId), | ||
m_slaveId(slaveId), | ||
m_startRegister(startRegister), | ||
m_endRegister(endRegister), | ||
m_functionCode(functionCode) | ||
{ | ||
QTimer::singleShot(20000, this, [this](){ | ||
finish(false); | ||
}); | ||
|
||
} | ||
|
||
quint8 SolarmanModbusReply::requestId() const | ||
{ | ||
return m_requestId; | ||
} | ||
|
||
quint8 SolarmanModbusReply::slaveId() const | ||
{ | ||
return m_slaveId; | ||
} | ||
|
||
quint16 SolarmanModbusReply::startRegister() const | ||
{ | ||
return m_startRegister; | ||
} | ||
|
||
quint16 SolarmanModbusReply::endRegister() const | ||
{ | ||
return m_endRegister; | ||
} | ||
|
||
quint16 SolarmanModbusReply::readRegister16(quint16 reg) const | ||
{ | ||
int index = (reg - m_startRegister) * 2; | ||
if (index < 0 || index + 1 >= m_data.length()) { | ||
qCWarning(dcSolarman()) << "Register" << reg << "out of range:" << m_startRegister << "-" << m_endRegister; | ||
return 0; | ||
} | ||
QByteArray clipped = m_data.right(m_data.length() - (reg - m_startRegister) * 2); | ||
QDataStream stream(clipped); | ||
quint16 ret = 0; | ||
stream >> ret; | ||
return ret; | ||
} | ||
|
||
quint32 SolarmanModbusReply::readRegister32(quint16 reg) const | ||
{ | ||
int index = (reg - m_startRegister) * 2; | ||
if (index < 0 || index + 3 >= m_data.length()) { | ||
qCWarning(dcSolarman()) << "Register" << reg << "out of range:" << m_startRegister << "-" << m_endRegister; | ||
return 0; | ||
} | ||
QByteArray clipped = m_data.right(m_data.length() - (reg - m_startRegister) * 2); | ||
QDataStream stream(clipped); | ||
quint32 ret = 0; | ||
quint16 tmp; | ||
stream >> tmp; | ||
ret = tmp; | ||
stream >> tmp; | ||
ret += (tmp << 16); | ||
return ret; | ||
} | ||
|
||
QString SolarmanModbusReply::readRegisterString(quint16 reg, quint8 registerCount) const | ||
{ | ||
int index = (reg - m_startRegister) * 2; | ||
if (index < 0 || (index + registerCount) * 2 >= m_data.length()) { | ||
return QString(); | ||
} | ||
QByteArray string = m_data.right(m_data.length() - (reg - m_startRegister) * 2).left(registerCount * 2); | ||
return string; | ||
} | ||
|
||
void SolarmanModbusReply::finish(bool success, const QByteArray &data) | ||
{ | ||
if (m_finished) { | ||
return; | ||
} | ||
m_finished = true; | ||
m_success = success; | ||
m_data = data; | ||
QMetaObject::invokeMethod(this, "finished", Qt::QueuedConnection, Q_ARG(bool, success)); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
#ifndef SOLARMANMODBUSREPLY_H | ||
#define SOLARMANMODBUSREPLY_H | ||
|
||
#include <QObject> | ||
#include "solarmanmodbus.h" | ||
|
||
class SolarmanModbusReply : public QObject | ||
{ | ||
friend class SolarmanModbus; | ||
Q_OBJECT | ||
public: | ||
explicit SolarmanModbusReply(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, SolarmanModbus::FunctionCode, QObject *parent = nullptr); | ||
|
||
quint8 requestId() const; | ||
quint8 slaveId() const; | ||
quint16 startRegister() const; | ||
quint16 endRegister() const; | ||
|
||
quint16 readRegister16(quint16 reg) const; | ||
quint32 readRegister32(quint16 reg) const; | ||
QString readRegisterString(quint16 reg, quint8 registerCount) const; | ||
|
||
signals: | ||
void finished(bool success); | ||
|
||
private: | ||
void finish(bool success, const QByteArray &data = QByteArray()); | ||
|
||
quint8 m_requestId = 0; | ||
quint8 m_slaveId = 0; | ||
quint16 m_startRegister = 0; | ||
quint16 m_endRegister = 0; | ||
SolarmanModbus::FunctionCode m_functionCode = SolarmanModbus::FunctionCode::Invalid; | ||
QByteArray m_data; | ||
bool m_success = false; | ||
bool m_finished = false; | ||
|
||
}; | ||
|
||
#endif // SOLARMANMODBUSREPLY_H |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,281 @@ | ||
import socket | ||
|
||
import yaml | ||
import struct | ||
|
||
|
||
|
||
|
||
# The parameters start in the "business field" | ||
# just after the first two bytes. | ||
OFFSET_PARAMS = 28 | ||
|
||
|
||
class ParameterParser: | ||
def __init__(self, lookups): | ||
self.result = {} | ||
self._lookups = lookups | ||
return | ||
|
||
def parse (self, rawData, start, length): | ||
for i in self._lookups['parameters']: | ||
for j in i['items']: | ||
print("parsing %s" % j["name"]) | ||
self.try_parse_field(rawData, j, start, length) | ||
return | ||
|
||
def get_result(self): | ||
return self.result | ||
|
||
|
||
def try_parse_field (self, rawData, definition, start, length): | ||
rule = definition['rule'] | ||
if rule == 1: | ||
self.try_parse_unsigned(rawData,definition, start, length) | ||
elif rule == 2: | ||
self.try_parse_signed(rawData,definition, start, length) | ||
elif rule == 3: | ||
self.try_parse_unsigned(rawData,definition, start, length) | ||
elif rule == 4: | ||
self.try_parse_signed(rawData,definition, start, length) | ||
elif rule == 5: | ||
self.try_parse_ascii(rawData,definition, start, length) | ||
elif rule == 6: | ||
self.try_parse_bits(rawData,definition, start, length) | ||
return | ||
|
||
def try_parse_signed (self, rawData, definition, start, length): | ||
title = definition['name'] | ||
scale = definition['scale'] | ||
value = 0 | ||
found = True | ||
shift = 0 | ||
maxint = 0 | ||
for r in definition['registers']: | ||
index = r - start # get the decimal value of the register' | ||
if (index >= 0) and (index < length): | ||
maxint <<= 16 | ||
maxint |= 0xFFFF | ||
offset = OFFSET_PARAMS + (index * 2) | ||
temp = struct.unpack('>H', rawData[offset:offset + 2])[0] | ||
value += (temp & 0xFFFF) << shift | ||
shift += 16 | ||
else: | ||
found = False | ||
if found: | ||
if 'offset' in definition: | ||
value = value - definition['offset'] | ||
|
||
if value > maxint/2: | ||
value = (value - maxint) * scale | ||
else: | ||
value = value * scale | ||
|
||
if self.is_integer_num (value): | ||
self.result[title] = int(value) | ||
else: | ||
self.result[title] = value | ||
return | ||
|
||
def try_parse_unsigned (self, rawData, definition, start, length): | ||
title = definition['name'] | ||
scale = definition['scale'] | ||
value = 0 | ||
found = True | ||
shift = 0 | ||
for r in definition['registers']: | ||
index = r - start # get the decimal value of the register' | ||
print("Reg: %s, index: %s" % (r, index)) | ||
if (index >= 0) and (index < length): | ||
offset = OFFSET_PARAMS + (index * 2) | ||
temp = struct.unpack('>H', rawData[offset:offset + 2])[0] | ||
value += (temp & 0xFFFF) << shift | ||
shift += 16 | ||
else: | ||
found = False | ||
if found: | ||
if 'lookup' in definition: | ||
self.result[title] = self.lookup_value (value, definition['lookup']) | ||
else: | ||
if 'offset' in definition: | ||
value = value - definition['offset'] | ||
|
||
value = value * scale | ||
if self.is_integer_num (value): | ||
self.result[title] = int(value) | ||
else: | ||
self.result[title] = value | ||
return | ||
|
||
|
||
def lookup_value (self, value, options): | ||
for o in options: | ||
if (o['key'] == value): | ||
return o['value'] | ||
return "LOOKUP" | ||
|
||
|
||
def try_parse_ascii (self, rawData, definition, start, length): | ||
title = definition['name'] | ||
found = True | ||
value = '' | ||
for r in definition['registers']: | ||
index = r - start # get the decimal value of the register' | ||
if (index >= 0) and (index < length): | ||
offset = OFFSET_PARAMS + (index * 2) | ||
temp = struct.unpack('>H', rawData[offset:offset + 2])[0] | ||
value = value + chr(temp >> 8) + chr(temp & 0xFF) | ||
else: | ||
found = False | ||
|
||
if found: | ||
self.result[title] = value | ||
return | ||
|
||
def try_parse_bits (self, rawData, definition, start, length): | ||
title = definition['name'] | ||
found = True | ||
value = [] | ||
for r in definition['registers']: | ||
index = r - start # get the decimal value of the register' | ||
if (index >= 0) and (index < length): | ||
offset = OFFSET_PARAMS + (index * 2) | ||
temp = struct.unpack('>H', rawData[offset:offset + 2])[0] | ||
value.append(hex(temp)) | ||
else: | ||
found = False | ||
|
||
if found: | ||
self.result[title] = value | ||
return | ||
|
||
def get_sensors (self): | ||
result = [] | ||
for i in self._lookups['parameters']: | ||
for j in i['items']: | ||
result.append(j) | ||
return result | ||
|
||
def is_integer_num(self, n): | ||
if isinstance(n, int): | ||
return True | ||
if isinstance(n, float): | ||
return n.is_integer() | ||
return False | ||
|
||
START_OF_MESSAGE = 0xA5 | ||
SEND_DATA_FIELD = [0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] | ||
CONTROL_CODE = [0x10, 0x45] | ||
SERIAL_NO = [0x00, 0x00] | ||
END_OF_MESSAGE = 0x15 | ||
|
||
mb_slaveid = 1 | ||
_serial = 4110403069 | ||
|
||
def modbus(data): | ||
POLY = 0xA001 | ||
crc = 0xFFFF | ||
for byte in data: | ||
crc ^= byte | ||
# print("C1: %s" % crc) | ||
for _ in range(8): | ||
crc = ((crc >> 1) ^ POLY | ||
if (crc & 0x0001) | ||
else crc >> 1) | ||
# print("C: %s" % crc) | ||
return crc | ||
|
||
def get_serial_hex(): | ||
# print("s0: %s %s" % (_serial, hex(_serial))) | ||
serial_hex = hex(_serial)[2:] | ||
# print("s1: %s" % serial_hex) | ||
serial_bytes = bytearray.fromhex(serial_hex) | ||
serial_bytes.reverse() | ||
# print("serial_bytes: %s" % serial_bytes.hex()) | ||
return serial_bytes | ||
|
||
|
||
def get_read_business_field(start, length, mb_fc): | ||
request_data = bytearray([mb_slaveid, mb_fc]) # Function Code | ||
request_data.extend(start.to_bytes(2, 'big')) | ||
request_data.extend(length.to_bytes(2, 'big')) | ||
crc = modbus(request_data) | ||
# print("CRC %s" % crc) | ||
request_data.extend(crc.to_bytes(2, 'little')) | ||
return request_data | ||
|
||
|
||
def generate_request(start, length, mb_fc): | ||
packet = bytearray([START_OF_MESSAGE]) | ||
packet_data = [] | ||
packet_data.extend (SEND_DATA_FIELD) | ||
buisiness_field = get_read_business_field(start, length, mb_fc) | ||
print("Biz field: %s" % buisiness_field.hex()); | ||
packet_data.extend(buisiness_field) | ||
print("packet_data: %s" % bytearray(packet_data).hex()) | ||
length = packet_data.__len__() | ||
print("Length %s" % length) | ||
packet.extend(length.to_bytes(2, "little")) | ||
packet.extend(CONTROL_CODE) | ||
packet.extend(SERIAL_NO) | ||
print("serialHex: %s" % get_serial_hex().hex()) | ||
packet.extend(get_serial_hex()) | ||
packet.extend(packet_data) | ||
#Checksum | ||
checksum = 0 | ||
for i in range(1,len(packet),1): | ||
# print("c: %s" % packet[i]) | ||
checksum += packet[i] | ||
# print("checksum: %s" % checksum) | ||
packet.append(checksum & 0xFF) | ||
packet.append(END_OF_MESSAGE) | ||
|
||
del packet_data | ||
del buisiness_field | ||
return packet | ||
|
||
|
||
def validate_checksum(packet): | ||
checksum = 0 | ||
length = len(packet) | ||
# Don't include the checksum and END OF MESSAGE (-2) | ||
for i in range(1,length-2,1): | ||
# print("c: %s" % packet[i]) | ||
checksum += packet[i] | ||
#print("c1: %s" % checksum) | ||
#print("checksum1: %s" % checksum) | ||
checksum &= 0xFF | ||
#print("checksum2: %s" % checksum) | ||
if checksum == packet[length-2]: | ||
return 1 | ||
else: | ||
return 0 | ||
|
||
start = 0x0001 | ||
end = 0x0070 | ||
fc = 0x03 | ||
|
||
print("Request: %s" % generate_request(start, end - start + 1, fc).hex()) | ||
|
||
|
||
request = generate_request(start, end - start + 1, fc) | ||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||
sock.settimeout(10) | ||
sock.connect(("10.10.10.195", 8899)) | ||
print(request.hex()) | ||
sock.sendall(request) # Request param 0x3B up to 0x71 | ||
raw_msg = sock.recv(1024) | ||
print(raw_msg.hex()) | ||
|
||
#raw_msg = bytearray.fromhex("a5ef0010150063fdc5fff40201d6e63400db2000003da3b9620103dc3232303230393036353500010000120c070000000112020700000bb800000101004b0000003c16080611200f000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004000a0000000004170000000a0000000000000417000000000000092400000000000000000000138800000000000000000000000002ee0000000000001270000000000000000000000000000000000000000000000000000000000000000000000000017d00130000000066149615") | ||
|
||
if validate_checksum(raw_msg) == 1: | ||
print("Good checksum") | ||
|
||
with open("deye.yaml") as f: | ||
parameter_definition = yaml.full_load(f) | ||
|
||
params = ParameterParser(parameter_definition) | ||
params.parse(raw_msg, start, end - start + 1) | ||
|
||
print("Result: %s" % params.get_result()) |
70 changes: 70 additions & 0 deletions
70
solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
<?xml version="1.0" encoding="utf-8"?> | ||
<!DOCTYPE TS> | ||
<TS version="2.1"> | ||
<context> | ||
<name>IntegrationPluginBosswerk</name> | ||
<message> | ||
<location filename="../integrationpluginbosswerk.cpp" line="82"/> | ||
<source>Please enter your login credentials.</source> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../integrationpluginbosswerk.cpp" line="93"/> | ||
<source>An error happened in the network communication.</source> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../integrationpluginbosswerk.cpp" line="108"/> | ||
<source>Failed to log in at the inverter.</source> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
</context> | ||
<context> | ||
<name>bosswerk</name> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="33"/> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="36"/> | ||
<source>Bosswerk</source> | ||
<extracomment>The name of the vendor ({26ec1591-cc37-4ac1-b943-04844e002601}) | ||
---------- | ||
The name of the plugin bosswerk ({595b7759-336d-4677-a014-1b0fd11f45ea})</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="39"/> | ||
<source>Connected</source> | ||
<extracomment>The name of the StateType ({b1a9bdf7-1c87-4c5d-b7e5-835697e7b7e5}) of ThingClass mix00</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="42"/> | ||
<source>Current power consumption</source> | ||
<extracomment>The name of the StateType ({044c26ce-67a7-4c81-99b1-4aa35285b109}) of ThingClass mix00</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="45"/> | ||
<source>MAC address</source> | ||
<extracomment>The name of the ParamType (ThingClass: mix00, Type: thing, ID: {6fbe5f08-3539-447d-9281-916abe9d8128})</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="48"/> | ||
<source>MI-300/600</source> | ||
<extracomment>The name of the ThingClass ({31ee3e61-eb3f-470b-8957-293fe65f404d})</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="51"/> | ||
<source>Signal strength</source> | ||
<extracomment>The name of the StateType ({4187873d-50dd-4470-8bd1-2787436db84d}) of ThingClass mix00</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
<message> | ||
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="54"/> | ||
<source>Total produced energy</source> | ||
<extracomment>The name of the StateType ({4a596301-3a8d-41de-bc97-275d23c0e5cd}) of ThingClass mix00</extracomment> | ||
<translation type="unfinished"></translation> | ||
</message> | ||
</context> | ||
</TS> |