Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
New Plugin: Solarman
Browse files Browse the repository at this point in the history
mzanetti committed Aug 9, 2022
1 parent 860fbac commit 969cce6
Showing 22 changed files with 1,814 additions and 3 deletions.
7 changes: 4 additions & 3 deletions nymea-plugins.pro
Original file line number Diff line number Diff line change
@@ -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 \
Binary file added solarman/.test.py.swp
Binary file not shown.
18 changes: 18 additions & 0 deletions solarman/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Bosswerk

This integration plugin allows to add Bosswerk Micro Inverters to nymea.

## Supported devices

* MI-300
* MI-600

## Requirements

The solar inverter needs to be connected to the same network as nymea. Once powered on, the
inverter will open a wireless hotspot named AP_XXXXXXXXXX where XXXXXXXXXX is the serial number
written on the casing. The default password for this WiFi is 12345678. After connecting,
open [http://10.10.100.254](http://10.10.100.254) with the browser and use the web interface
on the inverter to connect it to a WiFi with nymea in it.

Once done so, set up the inverter in nymea as any other thing.
Binary file added solarman/bosswerk.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
213 changes: 213 additions & 0 deletions solarman/deye.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@

requests:
- start: 0x0001
end: 0x0070
mb_functioncode: 0x03

parameters:
- group: solar
items:
- name: "PV1 Voltage"
class: "voltage"
state_class: "measurement"
uom: "V"
scale: 0.1
rule: 1
registers: [0x006D]
icon: 'mdi:solar-power'

- name: "PV2 Voltage"
class: "voltage"
state_class: "measurement"
uom: "V"
scale: 0.1
rule: 1
registers: [0x006F]
icon: 'mdi:solar-power'

- name: "PV1 Current"
class: "current"
uom: "A"
scale: 0.1
rule: 1
registers: [0x006E]
icon: 'mdi:solar-power'

- name: "PV2 Current"
class: "current"
state_class: "measurement"
uom: "A"
scale: 0.1
rule: 1
registers: [0x0070]
icon: 'mdi:solar-power'

- name: "Daily Production"
class: "energy"
state_class: "total"
uom: "kWh"
scale: 0.1
rule: 1
registers: [0x003C]
icon: 'mdi:solar-power'

- name: "Total Production"
class: "energy"
state_class: "total_increasing"
uom: "kWh"
scale: 0.1
rule: 3
registers: [0x003F,0x0040]
icon: 'mdi:solar-power'



- group: Grid
items:
- name: "Grid Voltage L-L(A)"
class: "voltage"
state_class: "measurement"
uom: "V"
scale: 0.1
rule: 1
registers: [0x0049]
icon: 'mdi:transmission-tower'

- name: "Grid Voltage L-L(B))"
class: "voltage"
state_class: "measurement"
uom: "V"
scale: 0.1
rule: 1
registers: [0x004A]
icon: 'mdi:transmission-tower'

- name: "Grid Voltage L-L(C)"
class: "voltage"
state_class: "measurement"
uom: "V"
scale: 0.1
rule: 1
registers: [0x004B]
icon: 'mdi:transmission-tower'

- name: "Grid Current A"
class: "current"
state_class: "measurement"
uom: "A"
scale: 0.1
rule: 2
registers: [0x004C]
icon: 'mdi:home-lightning-bolt'

- name: "Grid Current B"
class: "current"
state_class: "measurement"
uom: "A"
scale: 0.1
rule: 2
registers: [0x004D]
icon: 'mdi:home-lightning-bolt'

- name: "Grid Current C"
class: "current"
state_class: "measurement"
uom: "A"
scale: 0.1
rule: 2
registers: [0x004E]
icon: 'mdi:home-lightning-bolt'

- name: "Grid Frequency"
class: "current"
state_class: "measurement"
uom: "Hz"
scale: 0.01
rule: 1
registers: [0x004F]
icon: 'mdi:home-lightning-bolt'

- group: Inverter
items:
- name: "Running Status"
class: ""
state_class: ""
uom: ""
scale: 1
rule: 1
registers: [0x003B]
isstr: true
lookup:
- key: 0
value: "Stand-by"
- key: 1
value: "Self-checking"
- key: 2
value: "Normal"
- key: 3
value: "FAULT"
icon: 'mdi:home-lightning-bolt'

- name: "Total Ouput AC Power"
class: "power"
state_class: "measurement"
uom: "W"
scale: 0.1
rule: 3
registers: [0x0050,0x0051]
icon: 'mdi:home-lightning-bolt'

- name: "Input Active Power"
class: "power"
state_class: "measurement"
uom: "W"
scale: 0.1
rule: 3
registers: [0x0052, 0x0053]
icon: 'mdi:home-lightning-bolt'

- name: "Output Apparent Power"
class: "apparent_power"
state_class: "measurement"
uom: "VA"
scale: 0.1
rule: 3
registers: [0x0054, 0x0055]
icon: 'mdi:home-lightning-bolt'

- name: "Output Active Power"
class: "energy"
state_class: "measurement"
uom: "kWh"
scale: 0.1
rule: 3
registers: [0x0056, 0x0057]
icon: 'mdi:home-lightning-bolt'

- name: "Output Reactive Power"
class: "reactive_power"
state_class: "measurement"
uom: "VAR"
rule: 3
scale: 0.1
registers: [0x0058, 0x0059]
icon: 'mdi:home-lightning-bolt'

- name: "temp"
class: "temperature"
state_class: "measurement"
uom: "°C"
scale: 0.01
rule: 1
offset: 1000
registers: [0x005A]
icon: 'mdi:battery'

- name: "Inverter ID"
class: ""
state_class: ""
uom: ""
scale: 1
rule: 5
registers: [0x0003,0x0004,0x0005,0x0006,0x0007]
isstr: true
21 changes: 21 additions & 0 deletions solarman/discover.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import socket

request = "WIFIKIT-214028-READ"
address = ("<broadcast>", 48899)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.settimeout(1.0)

sock.sendto(request.encode(), address)

while True:
try:
data = sock.recv(1024)
a = data.decode().split(',')
if 3 == len(a):
print("ip %s" % a[0])
print("mac %s" % a[1])
print("serial %s" % int(a[2]))
except socket.timeout:
break
213 changes: 213 additions & 0 deletions solarman/integrationpluginsolarman.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright 2013 - 2022, nymea GmbH
* Contact: contact@nymea.io
*
* This file is part of nymea.
* This project including source code and documentation is protected by
* copyright law, and remains the property of nymea GmbH. All rights, including
* reproduction, publication, editing and translation, are reserved. The use of
* this project is subject to the terms of a license agreement to be concluded
* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
* under https://nymea.io/license
*
* GNU Lesser General Public License Usage
* Alternatively, this project may be redistributed and/or modified under the
* terms of the GNU Lesser General Public License as published by the Free
* Software Foundation; version 3. This project is distributed in the hope that
* it will be useful, but WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this project. If not, see <https://www.gnu.org/licenses/>.
*
* For any further details and any questions please contact us under
* contact@nymea.io or see our FAQ/Licensing Information on
* https://nymea.io/license/faq
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */


#include "integrationpluginsolarman.h"
#include "plugininfo.h"

#include <plugintimer.h>
#include <network/networkdevicediscovery.h>
#include <network/networkaccessmanager.h>
#include <network/networkdevicediscoveryreply.h>

#include <QNetworkReply>
#include <QAuthenticator>
#include <QUrlQuery>
#include <QJsonDocument>
#include <QMetaEnum>
#include <QUdpSocket>
#include <QFile>
#include <QDir>

#include "solarmandiscovery.h"
#include "solarmanmodbus.h"
#include "solarmanmodbusreply.h"

IntegrationPluginSolarman::IntegrationPluginSolarman()
{
QFile registerMappings(":/registermappings.json");
registerMappings.open(QFile::ReadOnly);
QJsonDocument jsonDoc = QJsonDocument::fromJson(registerMappings.readAll());
m_registerMappings = jsonDoc.toVariant().toMap();
}

IntegrationPluginSolarman::~IntegrationPluginSolarman()
{
}

void IntegrationPluginSolarman::discoverThings(ThingDiscoveryInfo *info)
{
SolarmanDiscovery *discovery = new SolarmanDiscovery(info);
connect(discovery, &SolarmanDiscovery::discoveryResults, info, [this, info](const QList<SolarmanDiscovery::DiscoveryResult> &results) {
foreach (const SolarmanDiscovery::DiscoveryResult &result, results) {
ThingDescriptor descriptor(deyeStringThingClassId, "Deye String Inverter", "Serial: " + result.serial + ", IP: " + result.ip.toString());
descriptor.setParams({{deyeStringThingSerialParamTypeId, result.serial}});

if (myThings().findByParams(descriptor.params())) {
descriptor.setThingId(myThings().findByParams(descriptor.params())->id());
}
info->addThingDescriptor(descriptor);
}
info->finish(Thing::ThingErrorNoError);
});
discovery->discover();
}

void IntegrationPluginSolarman::setupThing(ThingSetupInfo *info)
{
Thing *thing = info->thing();

SolarmanDiscovery *monitor = m_monitors.value(thing);
if (!monitor) {
monitor = new SolarmanDiscovery(thing);
m_monitors.insert(thing, monitor);
}

if (!monitor->monitor(thing->paramValue(deyeStringThingSerialParamTypeId).toString())) {
info->finish(Thing::ThingErrorHardwareFailure, QT_TR_NOOP("Unalbe to open network connection."));
m_monitors.take(thing)->deleteLater();
return;
}

SolarmanModbus *modbus = new SolarmanModbus(thing);
m_connections.insert(thing, modbus);

// For testing
// modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial);
// pollDevice(thing);

connect(monitor, &SolarmanDiscovery::monitorStateChanged, thing, [modbus, monitor](const SolarmanDiscovery::DiscoveryResult &result) {
if (result.online && !modbus->isConnected()) {
modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial);
}
});

connect(modbus, &SolarmanModbus::connectedChanged, thing, [this, thing, monitor, modbus](bool connected){
thing->setStateValue(deyeStringConnectedStateTypeId, connected);
if (connected) {
pollDevice(thing);
m_timers.value(thing)->reset();
m_timers.value(thing)->start();
} else {
m_timers.value(thing)->stop();
if (monitor->monitorResult().online) {
qCDebug(dcSolarman()) << "Reconnecting to solarman inverter";
modbus->connectToHost(monitor->monitorResult().ip, 8899, monitor->monitorResult().serial);
}
}
});

PluginTimer *timer = m_timers.take(thing);
if (!timer) {
// the inverter updates its internal values once per minute...
timer = hardwareManager()->pluginTimerManager()->registerTimer(21);
m_timers.insert(thing, timer);

connect(timer, &PluginTimer::timeout, thing, [this, thing](){
pollDevice(thing);
});
}

info->finish(Thing::ThingErrorNoError);
}

void IntegrationPluginSolarman::thingRemoved(Thing *thing)
{
m_monitors.remove(thing);
m_connections.remove(thing);
hardwareManager()->pluginTimerManager()->unregisterTimer(m_timers.take(thing));
}

void IntegrationPluginSolarman::pollDevice(Thing *thing)
{
SolarmanModbus *modbus = m_connections.value(thing);
if (!modbus->isConnected()) {
qCDebug(dcSolarman()) << "Inverter offline. Not polling.";
return;
}

QVariantMap mapping = m_registerMappings.value(thing->thingClass().name()).toMap();
quint8 slaveId = mapping.value("slaveId").toUInt();
quint16 startRegister = mapping.value("startRegister").toUInt();
quint16 endRegister = mapping.value("endRegister").toUInt();
QMetaEnum fcEnum = QMetaEnum::fromType<SolarmanModbus::FunctionCode>();
SolarmanModbus::FunctionCode functionCode = static_cast<SolarmanModbus::FunctionCode>(fcEnum.keyToValue(mapping.value("functionCode").toByteArray()));
QVariantList registers = mapping.value("registers").toList();


SolarmanModbusReply *reply = modbus->readRegisters(slaveId, startRegister, endRegister, functionCode);
qCDebug(dcSolarman()) << "Polling inverter" << thing->name() << "slaveID:" << slaveId << "Start:" << startRegister << "End:" << endRegister << "Request ID" << reply->requestId();
connect(reply, &SolarmanModbusReply::finished, thing, [modbus, thing, reply, registers](bool success){
if (!success) {
qCWarning(dcSolarman()) << "Polling failed..." << reply->requestId();
modbus->disconnectFromHost();
return;
}
qCDebug(dcSolarman()) << "modbus reply for" << thing->name() << reply->requestId();

foreach (const QVariant &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);
});

}
73 changes: 73 additions & 0 deletions solarman/integrationpluginsolarman.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
*
* Copyright 2013 - 2022, nymea GmbH
* Contact: contact@nymea.io
*
* This file is part of nymea.
* This project including source code and documentation is protected by
* copyright law, and remains the property of nymea GmbH. All rights, including
* reproduction, publication, editing and translation, are reserved. The use of
* this project is subject to the terms of a license agreement to be concluded
* with nymea GmbH in accordance with the terms of use of nymea GmbH, available
* under https://nymea.io/license
*
* GNU Lesser General Public License Usage
* Alternatively, this project may be redistributed and/or modified under the
* terms of the GNU Lesser General Public License as published by the Free
* Software Foundation; version 3. This project is distributed in the hope that
* it will be useful, but WITHOUT ANY WARRANTY; without even the implied
* warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this project. If not, see <https://www.gnu.org/licenses/>.
*
* For any further details and any questions please contact us under
* contact@nymea.io or see our FAQ/Licensing Information on
* https://nymea.io/license/faq
*
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */

#ifndef INTEGRATIONPLUGINSOLARMAN_H
#define INTEGRATIONPLUGINSOLARMAN_H

#include "integrations/integrationplugin.h"
#include "extern-plugininfo.h"

class PluginTimer;
class SolarmanDiscovery;
class SolarmanModbus;

class IntegrationPluginSolarman: public IntegrationPlugin
{
Q_OBJECT

Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginsolarman.json")
Q_INTERFACES(IntegrationPlugin)

public:
enum Method {
GET,
SET
};
Q_ENUM(Method)

explicit IntegrationPluginSolarman();
~IntegrationPluginSolarman();

void discoverThings(ThingDiscoveryInfo *info) override;
void setupThing(ThingSetupInfo *info) override;
void thingRemoved(Thing *thing) override;

private slots:
void pollDevice(Thing *thing);

private:
QHash<Thing*, SolarmanDiscovery*> m_monitors;
QHash<Thing*, SolarmanModbus*> m_connections;
QHash<Thing*, PluginTimer*> m_timers;

QVariantMap m_registerMappings;
};

#endif // INTEGRATIONPLUGINSOLARMAN_H
82 changes: 82 additions & 0 deletions solarman/integrationpluginsolarman.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"name": "solarman",
"displayName": "Solarman",
"id": "9a37f9db-4b82-4bbc-b1ab-7eed5a1f4b70",
"vendors": [
{
"name": "solarman",
"displayName": "Solarman",
"id": "d6bc0ecd-8cbe-4d5d-a606-0712c5f10978",
"thingClasses": [
{
"id": "9712b0f0-45a3-4c27-a675-4ef6a0f1dc1e",
"name": "deyeString",
"displayName": "Deye String Inverter",
"createMethods": ["discovery"],
"interfaces": [ "solarinverter", "wirelessconnectable" ],
"paramTypes": [
{
"id": "e23e8186-e3df-4dc1-87cf-59627e1d2a3d",
"name":"serial",
"displayName": "Serial number",
"type": "QString"
}
],
"stateTypes": [
{
"id": "7b110485-7b12-425b-b4c2-6a0a76db69cf",
"name": "connected",
"displayName": "Connected",
"displayNameEvent": "Connected changed",
"type": "bool",
"defaultValue": false,
"cached": false
},
{
"id": "1303b3fa-072f-468e-949c-2e1474ce8f1e",
"name": "signalStrength",
"displayName": "Signal strength",
"displayNameEvent": "Signal strength changed",
"type": "uint",
"unit": "Percentage",
"minValue": 0,
"maxValue": 100,
"defaultValue": 0,
"filter": "adaptive",
"cached": false
},
{
"id": "49e29f3b-d04b-4972-94e1-d5485405f716",
"name": "currentPower",
"displayName": "Current power consumption",
"displayNameEvent": "Current power consumption changed",
"type": "double",
"unit": "Watt",
"defaultValue": 0,
"cached": false
},
{
"id": "0118a424-6f93-4f37-a9da-17617b508a45",
"name": "totalEnergyProduced",
"displayName": "Total produced energy",
"displayNameEvent": "Total produced energy changed",
"type": "double",
"unit": "KiloWattHour",
"defaultValue": 0
},
{
"id": "bc8ed7f6-6139-43e4-8d49-32ec5020d493",
"name": "temperature",
"displayName": "Inverter temperature",
"displayNameEvent": "Inverter temperature changed",
"type": "double",
"unit": "DegreeCelsius",
"defaultValue": 0
}

]
}
]
}
]
}
13 changes: 13 additions & 0 deletions solarman/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"title": "Bosswerk",
"tagline": "Integrates Bosswerk solar inverters with nymea.",
"icon": "bosswerk.jpg",
"stability": "consumer",
"offline": true,
"technologies": [
"network"
],
"categories": [
"energy"
]
}
1 change: 1 addition & 0 deletions solarman/nc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WIFIKIT-214028-READ 10.10.10.255 48899
97 changes: 97 additions & 0 deletions solarman/registermappings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"deyeString": {
"slaveId": 1,
"startRegister": 1,
"endRegister": 116,
"functionCode": "ReadHoldingRegisters",
"registers": [
{
"index": 3,
"type": "string",
"length": 5,
"name": "Inverter serial"
},
{
"index": 16,
"type": "uint16",
"name": "Rated power",
"scale": 0.1
},
{
"index": 59,
"type": "uint16",
"name": "Inverter status"
},
{
"index": 60,
"type": "uint16",
"name": "Daily production (Active)",
"scale": 0.1
},
{
"index": 63,
"type": "uint32",
"name": "Total Production (Active)",
"state": "totalEnergyProduced",
"scale": 0.1
},
{
"index": 73,
"type": "uint16",
"name": "AC Voltage 1",
"scale": 0.1
},
{
"index": 74,
"type": "uint16",
"name": "AC Voltage 2",
"scale": 0.1
},
{
"index": 75,
"type": "uint16",
"name": "AC Voltage 3",
"scale": 0.1
},
{
"index": 76,
"type": "uint16",
"name": "AC Current 1",
"scale": 0.1
},
{
"index": 77,
"type": "uint16",
"name": "AC Current 2",
"scale": 0.1
},
{
"index": 78,
"type": "uint16",
"name": "AC Current 3",
"scale": 0.1
},
{
"index": 79,
"type": "uint16",
"name": "AC Output Frequency",
"scale": 0.01
},
{
"index": 86,
"name": "Total AC Output Power (Active)",
"type": "uint32",
"state": "currentPower",
"scale": -0.1
},
{
"index": 90,
"name": "Temerpature",
"type": "uint16",
"state": "temperature",
"offset": -1000,
"scale": 0.01
}
]
}
}
5 changes: 5 additions & 0 deletions solarman/registermappings.qrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/">
<file>registermappings.json</file>
</qresource>
</RCC>
21 changes: 21 additions & 0 deletions solarman/solarman.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
include(../plugins.pri)

QT += network

SOURCES += \
integrationpluginsolarman.cpp \
solarmandiscovery.cpp \
solarmanmodbus.cpp \
solarmanmodbusreply.cpp

HEADERS += \
integrationpluginsolarman.h \
solarmandiscovery.h \
solarmanmodbus.h \
solarmanmodbusreply.h

DISTFILES += \
registermappings.json

RESOURCES += \
registermappings.qrc
152 changes: 152 additions & 0 deletions solarman/solarmandiscovery.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#include "solarmandiscovery.h"

#include <QTimer>
#include <QUdpSocket>

#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcSolarman)

SolarmanDiscovery::SolarmanDiscovery(QObject *parent)
: QObject{parent}
{
}

bool SolarmanDiscovery::discover()
{
if (!initUdp()) {
return false;
}

if (m_discoveryTimer) {
qCDebug(dcSolarman()) << "Already discovering...";
return true;
}

m_discoveryTimer = new QTimer(this);
m_discoveryTimer->start(1000);

connect(m_discoveryTimer, &QTimer::timeout, this, [=](){
int counter = m_discoveryTimer->property("counter").toInt();
if (counter < 5) {
m_discoveryTimer->setProperty("counter", counter+1);
sendDiscoveryMessage();
} else {
emit discoveryResults(m_discoveryResults);
m_discoveryResults.clear();
delete m_discoveryTimer;
m_discoveryTimer = nullptr;
}
});

bool status = sendDiscoveryMessage();
if (!status) {
delete m_discoveryTimer;
m_discoveryTimer = nullptr;
}
return status;
}

bool SolarmanDiscovery::monitor(const QString &serial)
{
if (!initUdp()) {
return false;
}

m_monitoringResult.serial = serial;

if (!m_monitorTimer) {
m_monitorTimer = new QTimer(this);
m_monitorTimer->start(15000);
connect(m_monitorTimer, &QTimer::timeout, this, [=](){
sendDiscoveryMessage();

if (m_monitoringResult.lastSeen.msecsTo(QDateTime::currentDateTime()) > 60000) {
m_monitoringResult.online = false;
emit monitorStateChanged(m_monitoringResult);
}
});
}

bool status = sendDiscoveryMessage();
if (!status) {
delete m_monitorTimer;
m_monitorTimer = nullptr;
}
return status;
}

SolarmanDiscovery::DiscoveryResult SolarmanDiscovery::monitorResult() const
{
return m_monitoringResult;
}

void SolarmanDiscovery::onReadyRead()
{
while (m_udp->hasPendingDatagrams()) {
QByteArray data;
data.resize(m_udp->pendingDatagramSize());
QHostAddress host;
m_udp->readDatagram(data.data(), m_udp->pendingDatagramSize(), &host);
if (data == "WIFIKIT-214028-READ") {
continue;
}
qCDebug(dcSolarman) << "UDP datagram from" << host.toString() << data;

QStringList parts = QString(data).split(",");
if (parts.count() != 3) {
qCDebug(dcSolarman()) << "Unexpected discovery reply format:" << data;
continue;
}
DiscoveryResult result;
result.ip = parts.at(0);
result.mac = parts.at(1);
result.serial = parts.at(2);
result.online = true;

// Sanity check if the claimed IP matches with the sender
if (result.ip.toIPv4Address() != host.toIPv4Address()) {
qCDebug(dcSolarman) << "Sender IP doesn't match claimed IP:" << result.ip << host;
continue;
}

if (m_discoveryTimer) {
qCDebug(dcSolarman()) << "Found solarman device" << result.serial << "on" << result.ip;
if (!m_discoveryResults.contains(result)) {
m_discoveryResults.append(result);
}
}

if (m_monitorTimer) {
if (result.serial == m_monitoringResult.serial) {
m_monitoringResult = result;
m_monitoringResult.lastSeen = QDateTime::currentDateTime();
emit monitorStateChanged(result);
}
}
}
}

bool SolarmanDiscovery::initUdp()
{
if (m_udp) {
return true;
}

m_udp = new QUdpSocket(this);
connect(m_udp, &QUdpSocket::readyRead, this, &SolarmanDiscovery::onReadyRead);
bool status = m_udp->bind(48899, QUdpSocket::ShareAddress);
if (!status) {
qCWarning(dcSolarman()) << "Unable to bind UDP port 48899. SolarmanDiscovery won't work.";
delete m_udp;
m_udp = nullptr;
}
return status;
}

bool SolarmanDiscovery::sendDiscoveryMessage()
{
qCDebug(dcSolarman()) << "Discovering solarman inverters...";
QByteArray discoveryString("WIFIKIT-214028-READ");
int len = m_udp->writeDatagram(discoveryString, QHostAddress::Broadcast, 48899);
return len == discoveryString.length();
}
52 changes: 52 additions & 0 deletions solarman/solarmandiscovery.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#ifndef SOLARMANDISCOVERY_H
#define SOLARMANDISCOVERY_H

#include <QObject>
#include <QHostAddress>
#include <QTimer>
#include <QUdpSocket>
#include <QDateTime>

class SolarmanDiscovery : public QObject
{
Q_OBJECT
public:
class DiscoveryResult {
public:
QString serial;
QHostAddress ip;
QString mac;
bool online;
QDateTime lastSeen;
bool operator==(const DiscoveryResult &other) {
return serial == other.serial;
}
};

explicit SolarmanDiscovery(QObject *parent = nullptr);

bool discover();

bool monitor(const QString &serial);
DiscoveryResult monitorResult() const;

signals:
void discoveryResults(const QList<DiscoveryResult> &results);
void monitorStateChanged(const DiscoveryResult &result);

private slots:
void onReadyRead();

private:
bool initUdp();
bool sendDiscoveryMessage();

QList<DiscoveryResult> m_discoveryResults;

QUdpSocket *m_udp = nullptr;;
DiscoveryResult m_monitoringResult;
QTimer *m_discoveryTimer = nullptr;
QTimer *m_monitorTimer = nullptr;
};

#endif // SOLARMANDISCOVERY_H
305 changes: 305 additions & 0 deletions solarman/solarmanmodbus.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
#include "solarmanmodbus.h"
#include "solarmanmodbusreply.h"

#include <QDataStream>
#include <QHostAddress>
#include <QTimer>
#include <QLoggingCategory>
#include <QDateTime>
#include <QTimeZone>


Q_DECLARE_LOGGING_CATEGORY(dcSolarman)

#define MOCK_DATA 0

quint8 START_OF_MESSAGE = 0xA5;
quint8 END_OF_MESSAGE = 0x15;
quint8 FRAME_TYPE = 0x02;
quint16 requestCode = 0x4510;
quint16 responseCode = 0x1510;

SolarmanModbus::SolarmanModbus(QObject *parent)
: QObject{parent}
{
m_socket = new QTcpSocket(this);
connect(m_socket, &QTcpSocket::stateChanged, this, [=](QAbstractSocket::SocketState state){
qCDebug(dcSolarman()) << "Socket state changed:" << state;
if (state == QAbstractSocket::ConnectedState) {
emit connectedChanged(true);
} else if (state == QAbstractSocket::UnconnectedState){
emit connectedChanged(false);
}
}, Qt::QueuedConnection); // Otherwise socket->isOpen() may stll be true if the user reconnects right away.

connect(m_socket, &QTcpSocket::readyRead, this, [this](){
QByteArray data = m_socket->readAll();
processData(data);
});
}

void SolarmanModbus::connectToHost(const QHostAddress &host, quint16 port, const QString &serial)
{
if (m_serial != serial || m_host != host || m_port != port) {
m_serial = serial;
m_host = host;
m_port = port;
m_socket->close();
}

if (!m_socket->isOpen()) {
qCDebug(dcSolarman()) << "Connecting to" << host.toString() << port;
m_socket->connectToHost(host, port);
} else {
qCDebug(dcSolarman()) << "Still connected. Not reconnecting";
}
}

void SolarmanModbus::disconnectFromHost()
{
m_serial.clear();
m_socket->disconnectFromHost();
emit connectedChanged(false);
}

bool SolarmanModbus::isConnected() const
{
return m_socket->isOpen();
}

SolarmanModbusReply *SolarmanModbus::readRegisters(int slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode)
{

#if MOCK_DATA == 1
quint8 requestId = 0;
SolarmanModbusReply *reply = new SolarmanModbusReply(requestId, slaveId, startRegister, endRegister, functionCode, this);

QByteArray mockData = QByteArray::fromHex(
// Late afternoon
"a5f30010150033fdc5fff40201aaab35004b0600001e2dba620103e0010002013232303230393036353500010000120c070000000112020700000bb800000101004b0000003c160807111210000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004001100000000042a00000011000000000000042a0000000000000938000000000000000000001388000000000000000000000000042e00000000000014d20000000000000000000000000000000000000000000000000000000000000000000000000174001c0000000000356b15"
// End of day
// "a5f3001015002bfdc5fff402011a2900005a0600006adcef620103e0010002013232303230393036353500010000120c070000000112020700000bb800000101004b0000003c160807141d08000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004001300000000042c00000013000000000000042c0000000000000924000000000000000000001388000000000000000000000000001e000000000000106800000000000000000000000000000000000000000000000000000000000000000000000001460000000000008ce6c615"
);
m_pendingReplies.append(reply);
QMetaObject::invokeMethod(this, "processData", Qt::QueuedConnection, Q_ARG(QByteArray, mockData));
#else

quint8 requestId = m_requestIdCounter++;
SolarmanModbusReply *reply = new SolarmanModbusReply(requestId, slaveId, startRegister, endRegister, functionCode, this);

if (!m_socket->isOpen()) {
reply->finish(false);
return reply;
}
m_pendingReplies.append(reply);
connect(reply, &SolarmanModbusReply::finished, this, [this, reply](){
m_pendingReplies.removeAll(reply);
});
QByteArray request = createRequest(requestId, slaveId, startRegister, endRegister, functionCode, m_serial);
qCDebug(dcSolarman) << "Requesting:" << request.toHex();
m_socket->write(request);
#endif

return reply;
}

void SolarmanModbus::processData(const QByteArray &data)
{
qCDebug(dcSolarman) << "Data received:" << data.toHex();

if (!validateChecksum(data)) {
qCWarning(dcSolarman()) << "Checksum verification failed for payload:" << data.toHex();
return;
}

QDataStream stream(data);
stream.setByteOrder(QDataStream::LittleEndian);
quint8 startOfMessage;
stream >> startOfMessage;
if (startOfMessage != START_OF_MESSAGE) {
qCWarning(dcSolarman()) << "Skipping message... No Start of message found.";
return;
}
quint16 payloadLength;
stream >> payloadLength;
qCDebug(dcSolarman()) << "Payload length:" << payloadLength;
quint16 commandCode;
stream >> commandCode;

quint8 requestId;
stream >> requestId;
quint8 packetCounter;
stream >> packetCounter; // Probably for deduplication, dunno, just counts up to 0x6a (100) and resets to 1...


if (commandCode != responseCode) {
qCWarning(dcSolarman()) << "Unknown packet received:" << data.toHex();
disconnectFromHost();
return;
QByteArray rsp;
QDataStream rs(&rsp, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian);
rs << static_cast<quint8>(START_OF_MESSAGE);
rs << static_cast<quint16>(1); // len
rs << static_cast<quint16>(0x1710);
rs << requestId;
rs << packetCounter;
QByteArray serialHex = getSerialHex(m_serial);
qDebug() << "Serial hex:" << serialHex.toHex();
rs.writeRawData(serialHex.data(), serialHex.length());
rs << static_cast<quint8>(0);
rs << createChecksum(rsp);
rs << static_cast<quint8>(END_OF_MESSAGE);
m_socket->write(rsp);
return;
}


char serialStr[4];
stream.readRawData(serialStr, 4);
QByteArray serial(serialStr, 4);

if (serial != getSerialHex(m_serial)) {
qCWarning(dcSolarman()) << "Serial number does not match:" << serial.toHex() << getSerialHex(m_serial).toHex();
return;
}

// Skipping 10 unknown characters
quint8 frameType;
stream >> frameType;

quint8 sensorType;
stream >> sensorType;

quint32 deliveryTime;
stream >> deliveryTime;

quint32 powerOnTime;
stream >> powerOnTime;
// char unknownStr[10];
// stream.readRawData(unknownStr, 10);

quint32 timestamp;
stream >> timestamp;
qCDebug(dcSolarman()) << "Device time:" << QDateTime::fromMSecsSinceEpoch((qulonglong)timestamp * 1000, QTimeZone::utc());

quint8 slaveId;
stream >> slaveId;

quint8 rt;
stream >> rt;
FunctionCode functionCode = static_cast<FunctionCode>(rt);

quint8 registersLength;
stream >> registersLength;

char dataStr[registersLength];
stream.readRawData(dataStr, registersLength);
QByteArray registers(dataStr, registersLength);

quint16 modbusCRC;
stream >> modbusCRC;
// While the checksum on the outer package is quite crappy (I've seen it colliding easily), we still don't bother checking the modbus CRC if that matches...

qCDebug(dcSolarman()) << "Reply received: SlaveID" << slaveId << functionCode << "payload len" << registersLength << "poweruptime" << powerOnTime << "deliverytime" << deliveryTime << "frametype" << frameType;

foreach (SolarmanModbusReply *reply, m_pendingReplies) {
if (reply->requestId() == requestId) {
reply->finish(true, registers);
}
}
}

QByteArray SolarmanModbus::createRequest(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode, const QString &serialNumber) {


QByteArray packet;
QDataStream stream(&packet, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::LittleEndian);
stream << START_OF_MESSAGE;
QByteArray modbusPayload = createModbusPayload(slaveId, startRegister, endRegister, functionCode);
stream << static_cast<quint16>(15 + modbusPayload.length());
// stream.writeRawData((char*)CONTROL_CODE, 2);
stream << requestCode;
stream << requestId;
stream << requestId;//static_cast<quint8>(0x00); // This seems to be a static counter counting up unrelated on both ends, but the inverter doesn't care if we don't use it.
QByteArray serialHex = getSerialHex(serialNumber);
qDebug() << "Serial hex:" << serialHex.toHex();
stream.writeRawData(serialHex.data(), serialHex.length());
stream << FRAME_TYPE;
stream << static_cast<quint16>(0x0000); // Sensor type
stream << static_cast<quint32>(0x00000000); // Deliverytime
stream << static_cast<quint32>(0x00000000); // PowerOnTime
stream << static_cast<quint32>(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch() / 1000);
stream.writeRawData(modbusPayload.data(), modbusPayload.length());
stream << createChecksum(packet);
stream << END_OF_MESSAGE;
return packet;
}

QByteArray SolarmanModbus::createModbusPayload(quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode)
{
quint16 registerCount = endRegister - startRegister + 1;

QByteArray data;
QDataStream stream(&data, QIODevice::WriteOnly);
stream << static_cast<quint8>(slaveId);
stream << static_cast<quint8>(functionCode);
stream << startRegister;
stream << registerCount;
quint16 crc = createModbusCRC(data);
stream.setByteOrder(QDataStream::LittleEndian);
// stream.setByteOrder(QDataStream::BigEndian);
stream << crc;
return data;
}

quint16 SolarmanModbus::createModbusCRC(const QByteArray &data)
{
quint16 poly = 0xA001;
QDataStream stream(data);
quint16 crc = 0xFFFF;

while (!stream.atEnd()) {
quint8 byte;
stream >> byte;
crc ^= byte;
for (int i = 0; i < 8; i++) {
if (crc & 0x0001) {
crc = (crc >> 1) ^ poly;
} else {
crc = crc >> 1;
}
}
}
return crc;
}

QByteArray SolarmanModbus::getSerialHex(const QString &serialNumber) {
QByteArray serialHex = QByteArray::fromHex(QByteArray::number(serialNumber.toLongLong(), 16));
QByteArray reversed;
reversed.reserve(serialHex.size());
for (int i = serialHex.size() - 1; i >= 0; i--) {
reversed.append(serialHex.at(i));
}
return reversed;
}

quint8 SolarmanModbus::createChecksum(const QByteArray &data)
{
quint16 checksum = 0;
for (int i = 1; i < data.length(); i++) {
checksum += (quint8)data.at(i);
}
return checksum;
}

bool SolarmanModbus::validateChecksum(const QByteArray &packet) {
quint16 checksum = 0;
// Don't include the checksum and END OF MESSAGE (-2)
for (int i = 1; i < packet.length() - 2; i++) {
checksum += packet[i];
}
quint8 final = static_cast<quint8>(checksum);
return final == (quint8)packet.at(packet.length() - 2);
}
59 changes: 59 additions & 0 deletions solarman/solarmanmodbus.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#ifndef SOLARMANMODBUS_H
#define SOLARMANMODBUS_H

#include <QObject>
#include <QHostAddress>
#include <QTcpSocket>

class SolarmanModbusReply;

class SolarmanModbus : public QObject
{
Q_OBJECT
public:
enum FunctionCode {
Invalid = 0x00,
ReadCoilStatus = 0x01,
ReadInputStatus = 0x02,
ReadHoldingRegisters = 0x03,
ReadInputRegisters = 0x04,
ForceSingleCoil = 0x05,
ForceSingleRegister = 0x06,
ForceMultipleCoils = 0x0F,
PresetMultipleRegisters = 0x10
};
Q_ENUM(FunctionCode)

explicit SolarmanModbus(QObject *parent = nullptr);

void connectToHost(const QHostAddress &host, quint16 port, const QString &serial);
void disconnectFromHost();
bool isConnected() const;

SolarmanModbusReply* readRegisters(int slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode);

signals:
void connectedChanged(bool connected);

private slots:
void processData(const QByteArray &data);

private:
QByteArray createRequest(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode, const QString &serialNumber);
QByteArray createModbusPayload(quint8 slaveId, quint16 startRegister, quint16 endRegister, FunctionCode functionCode);
quint16 createModbusCRC(const QByteArray &data);
QByteArray getSerialHex(const QString &serialNumber);

quint8 createChecksum(const QByteArray &data);
bool validateChecksum(const QByteArray &data);

QString m_serial;
QHostAddress m_host;
quint16 m_port = 0;
QTcpSocket *m_socket = nullptr;
quint8 m_requestIdCounter = 0;

QList<SolarmanModbusReply*> m_pendingReplies;
};

#endif // SOLARMANMODBUS_H
94 changes: 94 additions & 0 deletions solarman/solarmanmodbusreply.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#include "solarmanmodbusreply.h"

#include <QTimer>
#include <QDataStream>
#include <QLoggingCategory>

Q_DECLARE_LOGGING_CATEGORY(dcSolarman)

SolarmanModbusReply::SolarmanModbusReply(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, SolarmanModbus::FunctionCode functionCode, QObject *parent)
: QObject{parent},
m_requestId(requestId),
m_slaveId(slaveId),
m_startRegister(startRegister),
m_endRegister(endRegister),
m_functionCode(functionCode)
{
QTimer::singleShot(20000, this, [this](){
finish(false);
});

}

quint8 SolarmanModbusReply::requestId() const
{
return m_requestId;
}

quint8 SolarmanModbusReply::slaveId() const
{
return m_slaveId;
}

quint16 SolarmanModbusReply::startRegister() const
{
return m_startRegister;
}

quint16 SolarmanModbusReply::endRegister() const
{
return m_endRegister;
}

quint16 SolarmanModbusReply::readRegister16(quint16 reg) const
{
int index = (reg - m_startRegister) * 2;
if (index < 0 || index + 1 >= m_data.length()) {
qCWarning(dcSolarman()) << "Register" << reg << "out of range:" << m_startRegister << "-" << m_endRegister;
return 0;
}
QByteArray clipped = m_data.right(m_data.length() - (reg - m_startRegister) * 2);
QDataStream stream(clipped);
quint16 ret = 0;
stream >> ret;
return ret;
}

quint32 SolarmanModbusReply::readRegister32(quint16 reg) const
{
int index = (reg - m_startRegister) * 2;
if (index < 0 || index + 3 >= m_data.length()) {
qCWarning(dcSolarman()) << "Register" << reg << "out of range:" << m_startRegister << "-" << m_endRegister;
return 0;
}
QByteArray clipped = m_data.right(m_data.length() - (reg - m_startRegister) * 2);
QDataStream stream(clipped);
quint32 ret = 0;
quint16 tmp;
stream >> tmp;
ret = tmp;
stream >> tmp;
ret += (tmp << 16);
return ret;
}

QString SolarmanModbusReply::readRegisterString(quint16 reg, quint8 registerCount) const
{
int index = (reg - m_startRegister) * 2;
if (index < 0 || (index + registerCount) * 2 >= m_data.length()) {
return QString();
}
QByteArray string = m_data.right(m_data.length() - (reg - m_startRegister) * 2).left(registerCount * 2);
return string;
}

void SolarmanModbusReply::finish(bool success, const QByteArray &data)
{
if (m_finished) {
return;
}
m_finished = true;
m_success = success;
m_data = data;
QMetaObject::invokeMethod(this, "finished", Qt::QueuedConnection, Q_ARG(bool, success));
}
40 changes: 40 additions & 0 deletions solarman/solarmanmodbusreply.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#ifndef SOLARMANMODBUSREPLY_H
#define SOLARMANMODBUSREPLY_H

#include <QObject>
#include "solarmanmodbus.h"

class SolarmanModbusReply : public QObject
{
friend class SolarmanModbus;
Q_OBJECT
public:
explicit SolarmanModbusReply(quint8 requestId, quint8 slaveId, quint16 startRegister, quint16 endRegister, SolarmanModbus::FunctionCode, QObject *parent = nullptr);

quint8 requestId() const;
quint8 slaveId() const;
quint16 startRegister() const;
quint16 endRegister() const;

quint16 readRegister16(quint16 reg) const;
quint32 readRegister32(quint16 reg) const;
QString readRegisterString(quint16 reg, quint8 registerCount) const;

signals:
void finished(bool success);

private:
void finish(bool success, const QByteArray &data = QByteArray());

quint8 m_requestId = 0;
quint8 m_slaveId = 0;
quint16 m_startRegister = 0;
quint16 m_endRegister = 0;
SolarmanModbus::FunctionCode m_functionCode = SolarmanModbus::FunctionCode::Invalid;
QByteArray m_data;
bool m_success = false;
bool m_finished = false;

};

#endif // SOLARMANMODBUSREPLY_H
281 changes: 281 additions & 0 deletions solarman/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
import socket

import yaml
import struct




# The parameters start in the "business field"
# just after the first two bytes.
OFFSET_PARAMS = 28


class ParameterParser:
def __init__(self, lookups):
self.result = {}
self._lookups = lookups
return

def parse (self, rawData, start, length):
for i in self._lookups['parameters']:
for j in i['items']:
print("parsing %s" % j["name"])
self.try_parse_field(rawData, j, start, length)
return

def get_result(self):
return self.result


def try_parse_field (self, rawData, definition, start, length):
rule = definition['rule']
if rule == 1:
self.try_parse_unsigned(rawData,definition, start, length)
elif rule == 2:
self.try_parse_signed(rawData,definition, start, length)
elif rule == 3:
self.try_parse_unsigned(rawData,definition, start, length)
elif rule == 4:
self.try_parse_signed(rawData,definition, start, length)
elif rule == 5:
self.try_parse_ascii(rawData,definition, start, length)
elif rule == 6:
self.try_parse_bits(rawData,definition, start, length)
return

def try_parse_signed (self, rawData, definition, start, length):
title = definition['name']
scale = definition['scale']
value = 0
found = True
shift = 0
maxint = 0
for r in definition['registers']:
index = r - start # get the decimal value of the register'
if (index >= 0) and (index < length):
maxint <<= 16
maxint |= 0xFFFF
offset = OFFSET_PARAMS + (index * 2)
temp = struct.unpack('>H', rawData[offset:offset + 2])[0]
value += (temp & 0xFFFF) << shift
shift += 16
else:
found = False
if found:
if 'offset' in definition:
value = value - definition['offset']

if value > maxint/2:
value = (value - maxint) * scale
else:
value = value * scale

if self.is_integer_num (value):
self.result[title] = int(value)
else:
self.result[title] = value
return

def try_parse_unsigned (self, rawData, definition, start, length):
title = definition['name']
scale = definition['scale']
value = 0
found = True
shift = 0
for r in definition['registers']:
index = r - start # get the decimal value of the register'
print("Reg: %s, index: %s" % (r, index))
if (index >= 0) and (index < length):
offset = OFFSET_PARAMS + (index * 2)
temp = struct.unpack('>H', rawData[offset:offset + 2])[0]
value += (temp & 0xFFFF) << shift
shift += 16
else:
found = False
if found:
if 'lookup' in definition:
self.result[title] = self.lookup_value (value, definition['lookup'])
else:
if 'offset' in definition:
value = value - definition['offset']

value = value * scale
if self.is_integer_num (value):
self.result[title] = int(value)
else:
self.result[title] = value
return


def lookup_value (self, value, options):
for o in options:
if (o['key'] == value):
return o['value']
return "LOOKUP"


def try_parse_ascii (self, rawData, definition, start, length):
title = definition['name']
found = True
value = ''
for r in definition['registers']:
index = r - start # get the decimal value of the register'
if (index >= 0) and (index < length):
offset = OFFSET_PARAMS + (index * 2)
temp = struct.unpack('>H', rawData[offset:offset + 2])[0]
value = value + chr(temp >> 8) + chr(temp & 0xFF)
else:
found = False

if found:
self.result[title] = value
return

def try_parse_bits (self, rawData, definition, start, length):
title = definition['name']
found = True
value = []
for r in definition['registers']:
index = r - start # get the decimal value of the register'
if (index >= 0) and (index < length):
offset = OFFSET_PARAMS + (index * 2)
temp = struct.unpack('>H', rawData[offset:offset + 2])[0]
value.append(hex(temp))
else:
found = False

if found:
self.result[title] = value
return

def get_sensors (self):
result = []
for i in self._lookups['parameters']:
for j in i['items']:
result.append(j)
return result

def is_integer_num(self, n):
if isinstance(n, int):
return True
if isinstance(n, float):
return n.is_integer()
return False

START_OF_MESSAGE = 0xA5
SEND_DATA_FIELD = [0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
CONTROL_CODE = [0x10, 0x45]
SERIAL_NO = [0x00, 0x00]
END_OF_MESSAGE = 0x15

mb_slaveid = 1
_serial = 4110403069

def modbus(data):
POLY = 0xA001
crc = 0xFFFF
for byte in data:
crc ^= byte
# print("C1: %s" % crc)
for _ in range(8):
crc = ((crc >> 1) ^ POLY
if (crc & 0x0001)
else crc >> 1)
# print("C: %s" % crc)
return crc

def get_serial_hex():
# print("s0: %s %s" % (_serial, hex(_serial)))
serial_hex = hex(_serial)[2:]
# print("s1: %s" % serial_hex)
serial_bytes = bytearray.fromhex(serial_hex)
serial_bytes.reverse()
# print("serial_bytes: %s" % serial_bytes.hex())
return serial_bytes


def get_read_business_field(start, length, mb_fc):
request_data = bytearray([mb_slaveid, mb_fc]) # Function Code
request_data.extend(start.to_bytes(2, 'big'))
request_data.extend(length.to_bytes(2, 'big'))
crc = modbus(request_data)
# print("CRC %s" % crc)
request_data.extend(crc.to_bytes(2, 'little'))
return request_data


def generate_request(start, length, mb_fc):
packet = bytearray([START_OF_MESSAGE])
packet_data = []
packet_data.extend (SEND_DATA_FIELD)
buisiness_field = get_read_business_field(start, length, mb_fc)
print("Biz field: %s" % buisiness_field.hex());
packet_data.extend(buisiness_field)
print("packet_data: %s" % bytearray(packet_data).hex())
length = packet_data.__len__()
print("Length %s" % length)
packet.extend(length.to_bytes(2, "little"))
packet.extend(CONTROL_CODE)
packet.extend(SERIAL_NO)
print("serialHex: %s" % get_serial_hex().hex())
packet.extend(get_serial_hex())
packet.extend(packet_data)
#Checksum
checksum = 0
for i in range(1,len(packet),1):
# print("c: %s" % packet[i])
checksum += packet[i]
# print("checksum: %s" % checksum)
packet.append(checksum & 0xFF)
packet.append(END_OF_MESSAGE)

del packet_data
del buisiness_field
return packet


def validate_checksum(packet):
checksum = 0
length = len(packet)
# Don't include the checksum and END OF MESSAGE (-2)
for i in range(1,length-2,1):
# print("c: %s" % packet[i])
checksum += packet[i]
#print("c1: %s" % checksum)
#print("checksum1: %s" % checksum)
checksum &= 0xFF
#print("checksum2: %s" % checksum)
if checksum == packet[length-2]:
return 1
else:
return 0

start = 0x0001
end = 0x0070
fc = 0x03

print("Request: %s" % generate_request(start, end - start + 1, fc).hex())


request = generate_request(start, end - start + 1, fc)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect(("10.10.10.195", 8899))
print(request.hex())
sock.sendall(request) # Request param 0x3B up to 0x71
raw_msg = sock.recv(1024)
print(raw_msg.hex())

#raw_msg = bytearray.fromhex("a5ef0010150063fdc5fff40201d6e63400db2000003da3b9620103dc3232303230393036353500010000120c070000000112020700000bb800000101004b0000003c16080611200f000000000abe07081450128e000000000000139c002c000000000000000000640000000000010000000000010000000000010000000000000000000000000000000000000004000a0000000004170000000a0000000000000417000000000000092400000000000000000000138800000000000000000000000002ee0000000000001270000000000000000000000000000000000000000000000000000000000000000000000000017d00130000000066149615")

if validate_checksum(raw_msg) == 1:
print("Good checksum")

with open("deye.yaml") as f:
parameter_definition = yaml.full_load(f)

params = ParameterParser(parameter_definition)
params.parse(raw_msg, start, end - start + 1)

print("Result: %s" % params.get_result())
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1">
<context>
<name>IntegrationPluginBosswerk</name>
<message>
<location filename="../integrationpluginbosswerk.cpp" line="82"/>
<source>Please enter your login credentials.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginbosswerk.cpp" line="93"/>
<source>An error happened in the network communication.</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../integrationpluginbosswerk.cpp" line="108"/>
<source>Failed to log in at the inverter.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>bosswerk</name>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="33"/>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="36"/>
<source>Bosswerk</source>
<extracomment>The name of the vendor ({26ec1591-cc37-4ac1-b943-04844e002601})
----------
The name of the plugin bosswerk ({595b7759-336d-4677-a014-1b0fd11f45ea})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="39"/>
<source>Connected</source>
<extracomment>The name of the StateType ({b1a9bdf7-1c87-4c5d-b7e5-835697e7b7e5}) of ThingClass mix00</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="42"/>
<source>Current power consumption</source>
<extracomment>The name of the StateType ({044c26ce-67a7-4c81-99b1-4aa35285b109}) of ThingClass mix00</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="45"/>
<source>MAC address</source>
<extracomment>The name of the ParamType (ThingClass: mix00, Type: thing, ID: {6fbe5f08-3539-447d-9281-916abe9d8128})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="48"/>
<source>MI-300/600</source>
<extracomment>The name of the ThingClass ({31ee3e61-eb3f-470b-8957-293fe65f404d})</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="51"/>
<source>Signal strength</source>
<extracomment>The name of the StateType ({4187873d-50dd-4470-8bd1-2787436db84d}) of ThingClass mix00</extracomment>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="../../../build/nymea-plugins-Desktop-Debug/bosswerk/plugininfo.h" line="54"/>
<source>Total produced energy</source>
<extracomment>The name of the StateType ({4a596301-3a8d-41de-bc97-275d23c0e5cd}) of ThingClass mix00</extracomment>
<translation type="unfinished"></translation>
</message>
</context>
</TS>

0 comments on commit 969cce6

Please sign in to comment.