New Plugin: Solarman

This commit is contained in:
Michael Zanetti 2022-08-08 15:11:22 +02:00
parent 860fbac0e8
commit c771aee038
17 changed files with 1301 additions and 3 deletions

View File

@ -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 \

21
solarman/README.md Normal file
View File

@ -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.

View File

@ -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 &registerVariant, 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);
});
}

View File

@ -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

View File

@ -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
}
]
}
]
}
]
}

13
solarman/meta.json Normal file
View File

@ -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"
]
}

View File

@ -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
}
]
}
}

View File

@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/">
<file>registermappings.json</file>
</qresource>
</RCC>

BIN
solarman/solarman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

21
solarman/solarman.pro Normal file
View File

@ -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

View File

@ -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();
}

View File

@ -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

305
solarman/solarmanmodbus.cpp Normal file
View File

@ -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);
}

59
solarman/solarmanmodbus.h Normal file
View File

@ -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

View File

@ -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));
}

View File

@ -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

View File

@ -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>