From c771aee0385276c60e5c1fb9bea80bc743705847 Mon Sep 17 00:00:00 2001 From: Michael Zanetti Date: Mon, 8 Aug 2022 15:11:22 +0200 Subject: [PATCH] New Plugin: Solarman --- nymea-plugins.pro | 7 +- solarman/README.md | 21 ++ solarman/integrationpluginsolarman.cpp | 213 ++++++++++++ solarman/integrationpluginsolarman.h | 73 +++++ solarman/integrationpluginsolarman.json | 82 +++++ solarman/meta.json | 13 + solarman/registermappings.json | 97 ++++++ solarman/registermappings.qrc | 5 + solarman/solarman.png | Bin 0 -> 26335 bytes solarman/solarman.pro | 21 ++ solarman/solarmandiscovery.cpp | 152 +++++++++ solarman/solarmandiscovery.h | 52 +++ solarman/solarmanmodbus.cpp | 305 ++++++++++++++++++ solarman/solarmanmodbus.h | 59 ++++ solarman/solarmanmodbusreply.cpp | 94 ++++++ solarman/solarmanmodbusreply.h | 40 +++ ...b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts | 70 ++++ 17 files changed, 1301 insertions(+), 3 deletions(-) create mode 100644 solarman/README.md create mode 100644 solarman/integrationpluginsolarman.cpp create mode 100644 solarman/integrationpluginsolarman.h create mode 100644 solarman/integrationpluginsolarman.json create mode 100644 solarman/meta.json create mode 100644 solarman/registermappings.json create mode 100644 solarman/registermappings.qrc create mode 100644 solarman/solarman.png create mode 100644 solarman/solarman.pro create mode 100644 solarman/solarmandiscovery.cpp create mode 100644 solarman/solarmandiscovery.h create mode 100644 solarman/solarmanmodbus.cpp create mode 100644 solarman/solarmanmodbus.h create mode 100644 solarman/solarmanmodbusreply.cpp create mode 100644 solarman/solarmanmodbusreply.h create mode 100644 solarman/translations/595b7759-336d-4677-a014-1b0fd11f45ea-en_US.ts 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 0000000000000000000000000000000000000000..3e720e317c5baeedb99cd2bc0da49f63756ae9ae GIT binary patch literal 26335 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
    8>4!rjoQ7Aj?5 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 y9zJ}iE=LG_r93eJQ9XPJoCo#) +#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 + + + +