diff --git a/nymea-plugins.pro b/nymea-plugins.pro
index d0802476..689ebdab 100644
--- a/nymea-plugins.pro
+++ b/nymea-plugins.pro
@@ -53,18 +53,19 @@ PLUGIN_DIRS = \
powerfox \
pushbullet \
pushnotifications \
- shelly \
- solarlog \
- systemmonitor \
reversessh \
senic \
serialportcommander \
sgready \
+ shelly \
simpleheatpump \
sma \
+ solarlog \
+ solarman \
somfytahoma \
sonos \
sunposition \
+ systemmonitor \
tado \
tasmota \
tcpcommander \
diff --git a/solarman/README.md b/solarman/README.md
new file mode 100644
index 00000000..1ddd12c9
--- /dev/null
+++ b/solarman/README.md
@@ -0,0 +1,21 @@
+# Solarman
+
+This integration plugin allows to add Solarman compatible inverters to nymea.
+
+Every inverter which is compatible with the Solarman app should be possible to use with this
+plugin, provided the Modbus registers for the inverter are defined in the registermappings.json.
+
+## Current supported devices
+
+* Bosswerk MI-300
+* Bosswerk 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.
diff --git a/solarman/integrationpluginsolarman.cpp b/solarman/integrationpluginsolarman.cpp
new file mode 100644
index 00000000..739cb9b9
--- /dev/null
+++ b/solarman/integrationpluginsolarman.cpp
@@ -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 .
+*
+* 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
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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 &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 functionCode = static_cast(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);
+ });
+
+}
diff --git a/solarman/integrationpluginsolarman.h b/solarman/integrationpluginsolarman.h
new file mode 100644
index 00000000..e0248281
--- /dev/null
+++ b/solarman/integrationpluginsolarman.h
@@ -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 .
+*
+* 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 m_monitors;
+ QHash m_connections;
+ QHash m_timers;
+
+ QVariantMap m_registerMappings;
+};
+
+#endif // INTEGRATIONPLUGINSOLARMAN_H
diff --git a/solarman/integrationpluginsolarman.json b/solarman/integrationpluginsolarman.json
new file mode 100644
index 00000000..9ffc2d11
--- /dev/null
+++ b/solarman/integrationpluginsolarman.json
@@ -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
+ }
+
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/solarman/meta.json b/solarman/meta.json
new file mode 100644
index 00000000..742b3690
--- /dev/null
+++ b/solarman/meta.json
@@ -0,0 +1,13 @@
+{
+ "title": "Solarman",
+ "tagline": "Integrates Solarman compatible solar (Deye, Bosswerk...) inverters with nymea.",
+ "icon": "solarman.png",
+ "stability": "consumer",
+ "offline": true,
+ "technologies": [
+ "network"
+ ],
+ "categories": [
+ "energy"
+ ]
+}
diff --git a/solarman/registermappings.json b/solarman/registermappings.json
new file mode 100644
index 00000000..e020c4a2
--- /dev/null
+++ b/solarman/registermappings.json
@@ -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
+ }
+ ]
+ }
+}
diff --git a/solarman/registermappings.qrc b/solarman/registermappings.qrc
new file mode 100644
index 00000000..863a379d
--- /dev/null
+++ b/solarman/registermappings.qrc
@@ -0,0 +1,5 @@
+
+
+ registermappings.json
+
+
diff --git a/solarman/solarman.png b/solarman/solarman.png
new file mode 100644
index 00000000..3e720e31
Binary files /dev/null and b/solarman/solarman.png differ
diff --git a/solarman/solarman.pro b/solarman/solarman.pro
new file mode 100644
index 00000000..4ea094c2
--- /dev/null
+++ b/solarman/solarman.pro
@@ -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
diff --git a/solarman/solarmandiscovery.cpp b/solarman/solarmandiscovery.cpp
new file mode 100644
index 00000000..7c26937f
--- /dev/null
+++ b/solarman/solarmandiscovery.cpp
@@ -0,0 +1,152 @@
+#include "solarmandiscovery.h"
+
+#include
+#include
+
+#include
+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();
+}
diff --git a/solarman/solarmandiscovery.h b/solarman/solarmandiscovery.h
new file mode 100644
index 00000000..5d14132e
--- /dev/null
+++ b/solarman/solarmandiscovery.h
@@ -0,0 +1,52 @@
+#ifndef SOLARMANDISCOVERY_H
+#define SOLARMANDISCOVERY_H
+
+#include
+#include
+#include
+#include
+#include
+
+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 &results);
+ void monitorStateChanged(const DiscoveryResult &result);
+
+private slots:
+ void onReadyRead();
+
+private:
+ bool initUdp();
+ bool sendDiscoveryMessage();
+
+ QList m_discoveryResults;
+
+ QUdpSocket *m_udp = nullptr;;
+ DiscoveryResult m_monitoringResult;
+ QTimer *m_discoveryTimer = nullptr;
+ QTimer *m_monitorTimer = nullptr;
+};
+
+#endif // SOLARMANDISCOVERY_H
diff --git a/solarman/solarmanmodbus.cpp b/solarman/solarmanmodbus.cpp
new file mode 100644
index 00000000..f5b51b71
--- /dev/null
+++ b/solarman/solarmanmodbus.cpp
@@ -0,0 +1,305 @@
+#include "solarmanmodbus.h"
+#include "solarmanmodbusreply.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+
+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(START_OF_MESSAGE);
+ rs << static_cast(1); // len
+ rs << static_cast(0x1710);
+ rs << requestId;
+ rs << packetCounter;
+ QByteArray serialHex = getSerialHex(m_serial);
+ qDebug() << "Serial hex:" << serialHex.toHex();
+ rs.writeRawData(serialHex.data(), serialHex.length());
+ rs << static_cast(0);
+ rs << createChecksum(rsp);
+ rs << static_cast(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(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(15 + modbusPayload.length());
+// stream.writeRawData((char*)CONTROL_CODE, 2);
+ stream << requestCode;
+ stream << requestId;
+ stream << requestId;//static_cast(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(0x0000); // Sensor type
+ stream << static_cast(0x00000000); // Deliverytime
+ stream << static_cast(0x00000000); // PowerOnTime
+ stream << static_cast(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(slaveId);
+ stream << static_cast(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(checksum);
+ return final == (quint8)packet.at(packet.length() - 2);
+}
diff --git a/solarman/solarmanmodbus.h b/solarman/solarmanmodbus.h
new file mode 100644
index 00000000..2d13123e
--- /dev/null
+++ b/solarman/solarmanmodbus.h
@@ -0,0 +1,59 @@
+#ifndef SOLARMANMODBUS_H
+#define SOLARMANMODBUS_H
+
+#include
+#include
+#include
+
+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 m_pendingReplies;
+};
+
+#endif // SOLARMANMODBUS_H
diff --git a/solarman/solarmanmodbusreply.cpp b/solarman/solarmanmodbusreply.cpp
new file mode 100644
index 00000000..5de9c9b3
--- /dev/null
+++ b/solarman/solarmanmodbusreply.cpp
@@ -0,0 +1,94 @@
+#include "solarmanmodbusreply.h"
+
+#include
+#include
+#include
+
+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));
+}
diff --git a/solarman/solarmanmodbusreply.h b/solarman/solarmanmodbusreply.h
new file mode 100644
index 00000000..6662b351
--- /dev/null
+++ b/solarman/solarmanmodbusreply.h
@@ -0,0 +1,40 @@
+#ifndef SOLARMANMODBUSREPLY_H
+#define SOLARMANMODBUSREPLY_H
+
+#include
+#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
diff --git a/solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts b/solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts
new file mode 100644
index 00000000..7f795b8d
--- /dev/null
+++ b/solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts
@@ -0,0 +1,70 @@
+
+
+
+
+ IntegrationPluginBosswerk
+
+
+ Please enter your login credentials.
+
+
+
+
+ An error happened in the network communication.
+
+
+
+
+ Failed to log in at the inverter.
+
+
+
+
+ bosswerk
+
+
+
+ Bosswerk
+ The name of the vendor ({26ec1591-cc37-4ac1-b943-04844e002601})
+----------
+The name of the plugin bosswerk ({595b7759-336d-4677-a014-1b0fd11f45ea})
+
+
+
+
+ Connected
+ The name of the StateType ({b1a9bdf7-1c87-4c5d-b7e5-835697e7b7e5}) of ThingClass mix00
+
+
+
+
+ Current power consumption
+ The name of the StateType ({044c26ce-67a7-4c81-99b1-4aa35285b109}) of ThingClass mix00
+
+
+
+
+ MAC address
+ The name of the ParamType (ThingClass: mix00, Type: thing, ID: {6fbe5f08-3539-447d-9281-916abe9d8128})
+
+
+
+
+ MI-300/600
+ The name of the ThingClass ({31ee3e61-eb3f-470b-8957-293fe65f404d})
+
+
+
+
+ Signal strength
+ The name of the StateType ({4187873d-50dd-4470-8bd1-2787436db84d}) of ThingClass mix00
+
+
+
+
+ Total produced energy
+ The name of the StateType ({4a596301-3a8d-41de-bc97-275d23c0e5cd}) of ThingClass mix00
+
+
+
+